Compare commits

...

127 Commits

Author SHA1 Message Date
dgtlmoon 0bd20f5a64 Hide UI cell info if short doc
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v7 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v8 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (main) (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
2025-12-15 15:31:53 +01:00
dgtlmoon 5c324719e9 Merge branch 'dev' into history-preview-ignore-text-highlighting 2025-12-15 15:21:06 +01:00
dgtlmoon 8ae87465d9 Update text 2025-12-15 14:45:00 +01:00
dgtlmoon dde25217ac UI tweaks 2025-12-15 14:30:29 +01:00
dependabot[bot] 6158bb48b8 Update pytest requirement from ~=7.2 to ~=9.0 (#3676)
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v7 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v8 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (main) (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
2025-12-15 11:46:33 +01:00
dependabot[bot] d4fc1a3b6e Bump the all group with 3 updates (#3678) 2025-12-15 11:45:54 +01:00
dependabot[bot] f39b5e5a46 Update jsonschema requirement from ~=4.0 to ~=4.25 (#3618)
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v7 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v8 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (main) (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
2025-12-15 00:04:32 +01:00
dgtlmoon 58633c661f tweak
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
2025-12-13 16:18:49 +01:00
dgtlmoon 6942f0bd3e Refactor of 'extract' functionality 2025-12-13 16:16:00 +01:00
dgtlmoon 67ecf1fb53 wip
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
2025-12-12 15:31:56 +01:00
dgtlmoon 8b553f486f Shuffling endpoints 2025-12-12 15:13:45 +01:00
dgtlmoon cd45492ebb Shuffling endpoint 2025-12-12 15:13:24 +01:00
dgtlmoon c1a88cb9ca UI fixes 2025-12-12 14:45:17 +01:00
dgtlmoon eca2f9df59 Merge branch 'dev' into history-preview-ignore-text-highlighting
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v7 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v8 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (main) (push) Has been cancelled
2025-12-12 11:45:37 +01:00
dgtlmoon 6bacc7b457 Merge branch 'master' into history-preview-ignore-text-highlighting 2025-12-12 11:45:31 +01:00
dgtlmoon 30ba603956 UI - 'Recheck all' should return back to the correct group/tag (#3673)
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v7 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v8 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (main) (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
2025-12-11 17:24:29 +01:00
dependabot[bot] 3147c5a3e2 Update pluggy requirement from ~=1.5 to ~=1.6 (#3616) 2025-12-11 17:16:30 +01:00
dgtlmoon f599efacab Pluggable content fetchers (#3653) 2025-12-11 17:16:14 +01:00
dgtlmoon d7dbc50d70 UI - Notification error text output fix #3669 #3280 (#3672) 2025-12-11 16:57:06 +01:00
dgtlmoon 51bb358ea7 Improving dev workflow 2025-11-28 16:20:11 +01:00
dgtlmoon fe4df1d41f 'dev' container should be only built on 'dev' branch 2025-11-28 16:16:23 +01:00
dgtlmoon a0f6a0b386 Merge branch 'master' into history-preview-ignore-text-highlighting
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
2025-11-27 17:33:32 +01:00
dgtlmoon c84171ac2d UI tweaks
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
2025-11-15 16:30:22 +01:00
dgtlmoon 6dc1f55f3f Merge branch 'master' into history-preview-ignore-text-highlighting
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
2025-11-14 18:17:12 +01:00
dgtlmoon 5510cbc00c test fix
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v7 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v8 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (main) (push) Has been cancelled
2025-11-13 21:02:40 +01:00
dgtlmoon a8d1bf41e8 separate out the difference renderer to the processor 2025-11-13 20:57:54 +01:00
dgtlmoon ebd7f7caa1 Merge branch 'master' into history-preview-ignore-text-highlighting 2025-11-13 20:43:26 +01:00
dgtlmoon 59e6be3465 Merge branch 'master' into history-preview-ignore-text-highlighting 2025-11-13 15:29:39 +01:00
dgtlmoon 2c0b5b65c1 Merge branch 'master' into history-preview-ignore-text-highlighting
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v7 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v8 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (main) (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
2025-11-07 17:24:49 +01:00
dgtlmoon f7a9c29d93 UI tweaks
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
2025-11-04 12:25:41 +01:00
dgtlmoon b22ed7ed88 Adding keyboard nav 2025-11-04 10:18:33 +01:00
dgtlmoon cfa9c19309 optimise vars 2025-11-04 09:53:29 +01:00
dgtlmoon 3cfc97637a Label fixes etc 2025-11-04 09:38:22 +01:00
dgtlmoon c315021f6e refactor form widgets
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
2025-11-04 08:58:40 +01:00
dgtlmoon a7fd101efd WIP 2025-11-04 08:31:48 +01:00
dgtlmoon 416869681e Merge branch 'master' into history-preview-ignore-text-highlighting 2025-11-04 08:26:21 +01:00
dgtlmoon bdb21021a6 Merge branch 'master' into history-preview-ignore-text-highlighting
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v7 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v8 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (main) (push) Has been cancelled
2025-11-03 19:08:03 +01:00
dgtlmoon 7c3241dffc WIP
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
2025-11-03 16:47:54 +01:00
dgtlmoon be4f0e0795 label fix 2025-11-03 16:33:44 +01:00
dgtlmoon e0e79188bb Only render/diff used diff* tokens 2025-11-03 13:03:45 +01:00
dgtlmoon 168b4d4ff8 Set the notification body at process time 2025-11-03 12:41:26 +01:00
dgtlmoon 0e29b74388 WIP
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
2025-11-02 00:24:15 +01:00
dgtlmoon 76c6a602fa merge fix
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
2025-10-31 16:02:51 +01:00
dgtlmoon 843659d9e9 Merging
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v7 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v8 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (main) (push) Has been cancelled
2025-10-31 15:46:18 +01:00
dgtlmoon 61f6bec142 Merge branch 'master' into history-preview-ignore-text-highlighting 2025-10-31 10:25:37 +01:00
dgtlmoon 0cc98af2c1 wip
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
2025-10-30 11:02:55 +01:00
dgtlmoon c7924537d3 misc improvements 2025-10-30 10:59:45 +01:00
dgtlmoon 3897653673 Merge branch 'master' into history-preview-ignore-text-highlighting 2025-10-30 10:23:32 +01:00
dgtlmoon 3e482014c0 WIP
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
2025-10-29 17:08:13 +01:00
dgtlmoon d4a7f6f198 Merge branch 'master' into history-preview-ignore-text-highlighting 2025-10-29 15:38:52 +01:00
dgtlmoon 507008990d WIP 2025-10-29 15:36:23 +01:00
dgtlmoon bf070e617f Merge branch 'master' into history-preview-ignore-text-highlighting
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v7 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v8 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (main) (push) Has been cancelled
2025-10-28 22:39:58 +01:00
dgtlmoon 0d0368846a oops 2025-10-28 22:31:45 +01:00
dgtlmoon 08169c23f3 fix import
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v7 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v8 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (main) (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
2025-10-28 22:26:02 +01:00
dgtlmoon a8192f608f Merge branch 'master' into history-preview-ignore-text-highlighting 2025-10-28 22:24:41 +01:00
dgtlmoon 21bf3827e7 WIP
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
2025-10-27 23:39:09 +01:00
dgtlmoon ea6623115a Ensure linefeed is present on diff view 2025-10-27 23:21:34 +01:00
dgtlmoon 6f7d3b689d Merge branch 'master' into history-preview-ignore-text-highlighting
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
2025-10-27 19:01:55 +01:00
dgtlmoon 95380cbd20 unused
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v7 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v8 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (main) (push) Has been cancelled
2025-10-27 12:28:15 +01:00
dgtlmoon c227c4308c and the rest of unit test 2025-10-27 11:51:58 +01:00
dgtlmoon ea778450b2 Fixing patch check 2025-10-27 11:49:10 +01:00
dgtlmoon 8be6b91990 Merge branch 'master' into history-preview-ignore-text-highlighting 2025-10-27 11:41:44 +01:00
dgtlmoon a8d06e9d69 No need to define line feed sep
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
2025-10-26 12:10:52 +01:00
dgtlmoon 6c166ba2c4 dont use word mode in text diff mode 2025-10-26 12:09:18 +01:00
dgtlmoon a3b3497f7c Fix test 2025-10-26 11:30:04 +01:00
dgtlmoon 339106c5a9 tweak docs again
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
2025-10-26 01:06:47 +02:00
dgtlmoon 63dfb395ef tweak to API 2025-10-26 01:04:05 +02:00
dgtlmoon c4072269dd Rebuild docs 2025-10-26 00:52:31 +02:00
dgtlmoon 2ad7d4633c bump docs 2025-10-26 00:52:12 +02:00
dgtlmoon bce3b00728 Needed some delay? 2025-10-26 00:44:08 +02:00
dgtlmoon 5b5449e034 Tidy tests and word_diff handling 2025-10-26 00:41:12 +02:00
dgtlmoon a3a93d2081 Adding API endpoint, rebuild docs 2025-10-26 00:07:49 +02:00
dgtlmoon 650b1799c8 Removing old vars, fixing tests 2025-10-25 23:39:49 +02:00
dgtlmoon ea7f2b1752 oops
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
2025-10-25 20:23:11 +02:00
dgtlmoon 892ea6b198 Fix markup 2025-10-25 20:12:34 +02:00
dgtlmoon 10e3db50f6 WIP 2025-10-25 20:06:18 +02:00
dgtlmoon e66229d26b Merge branch 'master' into history-preview-ignore-text-highlighting 2025-10-25 19:49:36 +02:00
dgtlmoon 060fdcf3f5 Merge branch 'master' into history-preview-ignore-text-highlighting
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v7 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v8 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (main) (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
2025-10-23 14:26:32 +02:00
dgtlmoon f750fa1765 Merge branch 'master' into history-preview-ignore-text-highlighting
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v7 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v8 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (main) (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
2025-10-21 15:57:00 +02:00
dgtlmoon 6a28a6a42f Merge branch 'master' into history-preview-ignore-text-highlighting
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built 📦 package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
2025-10-14 15:59:59 +02:00
dgtlmoon 6aba43419e WIP
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built 📦 package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v7 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v8 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (main) (push) Has been cancelled
2025-10-13 20:21:50 +02:00
dgtlmoon cb31e6eac6 WIP 2025-10-13 19:42:04 +02:00
dgtlmoon a172d00b9e WIP 2025-10-13 19:11:02 +02:00
dgtlmoon 97b0e12fd3 WIP 2025-10-13 18:45:24 +02:00
dgtlmoon a389084407 Lets go with line highlighting with sub words 2025-10-13 18:38:24 +02:00
dgtlmoon 961994abcf refactor 2025-10-13 17:46:53 +02:00
dgtlmoon 2709ba6772 Merge branch 'master' into history-preview-ignore-text-highlighting 2025-10-13 16:35:31 +02:00
dgtlmoon 82b2bf5cb0 Merge branch 'master' into history-preview-ignore-text-highlighting
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v7 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v8 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (main) (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built 📦 package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
2025-10-09 17:38:55 +02:00
dgtlmoon 5bbc33fd36 Merge branch 'master' into history-preview-ignore-text-highlighting
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built 📦 package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
2025-10-09 00:21:48 +02:00
dgtlmoon ab1b8e90cd adding cookie preferences for form defaults
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built 📦 package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
2025-10-08 15:18:51 +02:00
dgtlmoon 0f6f2a9b9c WIP 2025-10-08 15:18:40 +02:00
dgtlmoon ea45c706be redlines hacks not needed
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v7 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v8 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (main) (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built 📦 package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
2025-10-06 19:11:37 +02:00
dgtlmoon 1d3cadc773 back on 2025-10-06 19:10:58 +02:00
dgtlmoon bd3e2dc9c9 WIP 2025-10-06 19:07:32 +02:00
dgtlmoon 2e1e301915 Merge branch 'master' into history-preview-ignore-text-highlighting 2025-10-06 17:23:20 +02:00
dgtlmoon 824a1ceb96 WIP 2025-10-06 17:23:05 +02:00
dgtlmoon d7aac2f86c Unify testing with actual defined labels 2025-10-06 17:16:13 +02:00
dgtlmoon 8a254edcf3 unit test fixes 2025-10-06 17:11:54 +02:00
dgtlmoon ddeb90752a WIP 2025-10-06 17:04:38 +02:00
dgtlmoon 10ff8516e2 Adding custom formats 2025-10-06 17:00:37 +02:00
dgtlmoon 0fbd9b22bc tweaks 2025-10-06 16:53:56 +02:00
dgtlmoon ef437e1af4 Small hack to make it act like the previous implementation (whole line changes on new lines) 2025-10-06 16:47:23 +02:00
dgtlmoon 76951efa1b remove spaces from around diff 2025-10-06 14:43:20 +02:00
dgtlmoon 40418b29fb use redlines library for better line-level word differences
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built 📦 package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v7 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v8 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (main) (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
2025-10-06 10:46:47 +02:00
dgtlmoon 12e5f369aa Merge branch 'master' into history-preview-ignore-text-highlighting
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v7 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v8 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (main) (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built 📦 package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
2025-10-03 17:22:45 +02:00
dgtlmoon be1b9ed4db Add more content to test 2025-10-03 17:17:37 +02:00
dgtlmoon c55b8f2e36 Adding LINE_SIMILARITY_THRESHOLD_FOR_WORD_DIFF 2025-10-03 17:06:48 +02:00
dgtlmoon 4c764bdfed fix for output 2025-10-03 11:53:01 +02:00
dgtlmoon 50958ee1f1 text_json_diff/processor.py should also obey ignore_junk when special options like filter_text_added are added 2025-10-03 10:57:16 +02:00
dgtlmoon a57d046b0c Option to ignore junk/whitespace etc 2025-10-03 10:55:56 +02:00
dgtlmoon 98745bbe00 fix test
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built 📦 package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v7 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v8 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (main) (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
2025-10-03 10:37:53 +02:00
dgtlmoon 363e8225a0 Correctly connect case_insensitive option 2025-10-03 10:11:00 +02:00
dgtlmoon ea5ae13e83 Improving diff 2025-10-03 10:03:40 +02:00
dgtlmoon 2598eb7e3e Merge branch 'master' into history-preview-ignore-text-highlighting
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v7 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v8 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (main) (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built 📦 package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
2025-10-01 12:40:24 +02:00
dgtlmoon 2a69365337 Adding "Strip ignored lines" 2025-10-01 11:02:40 +02:00
dgtlmoon ff9f09ba80 Adding helper text 2025-10-01 09:57:33 +02:00
dgtlmoon 5cfe758cce Adding simple blocked text highlight test
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v7 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v8 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (main) (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built 📦 package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
2025-09-29 14:45:43 +02:00
dgtlmoon 25cb637533 Merge branch 'master' into history-preview-ignore-text-highlighting 2025-09-29 14:13:27 +02:00
dgtlmoon 7c8bbe6ece remove debug 2025-09-29 14:01:34 +02:00
dgtlmoon 6b031502a3 Update message 2025-09-29 14:00:39 +02:00
dgtlmoon 35c22c5cc7 Remove debug 2025-09-29 13:59:51 +02:00
dgtlmoon d87e17023a WIP 2025-09-29 13:59:26 +02:00
dgtlmoon f36a9799c1 Merge branch 'master' into history-preview-ignore-text-highlighting 2025-09-29 11:46:59 +02:00
dgtlmoon 9eb4af12b5 Ignore text - adding test
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built 📦 package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
2025-09-25 11:25:42 +02:00
dgtlmoon 12a1c200a3 tweaking for test 2025-09-23 15:06:01 +02:00
dgtlmoon a5faab6a5c remove diff min 2025-09-23 14:45:32 +02:00
dgtlmoon 7ca3373d1f Use server side "history" rendering 2025-09-23 14:43:19 +02:00
116 changed files with 4638 additions and 1148 deletions
+4 -3
View File
@@ -15,6 +15,7 @@ on:
push:
branches:
- master
- dev
jobs:
metadata:
@@ -46,7 +47,7 @@ jobs:
python-version: 3.11
- name: Cache pip packages
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
@@ -92,10 +93,10 @@ jobs:
version: latest
driver-opts: image=moby/buildkit:master
# master branch -> :dev container tag
# dev branch -> :dev container tag
- name: Build and push :dev
id: docker_build
if: ${{ github.ref }} == "refs/heads/master"
if: ${{ github.ref == 'refs/heads/dev' }}
uses: docker/build-push-action@v6
with:
context: ./
+3 -3
View File
@@ -21,7 +21,7 @@ jobs:
- name: Build a binary wheel and a source tarball
run: python3 -m build
- name: Store the distribution packages
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v6
with:
name: python-package-distributions
path: dist/
@@ -34,7 +34,7 @@ jobs:
- build
steps:
- name: Download all the dists
uses: actions/download-artifact@v6
uses: actions/download-artifact@v7
with:
name: python-package-distributions
path: dist/
@@ -93,7 +93,7 @@ jobs:
steps:
- name: Download all the dists
uses: actions/download-artifact@v6
uses: actions/download-artifact@v7
with:
name: python-package-distributions
path: dist/
+1 -1
View File
@@ -51,7 +51,7 @@ jobs:
python-version: 3.11
- name: Cache pip packages
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
@@ -29,7 +29,7 @@ jobs:
python-version: ${{ env.PYTHON_VERSION }}
- name: Cache pip packages
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-py${{ env.PYTHON_VERSION }}-${{ hashFiles('requirements.txt') }}
@@ -52,7 +52,7 @@ jobs:
docker save test-changedetectionio -o /tmp/test-changedetectionio.tar
- name: Upload Docker image artifact
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v6
with:
name: test-changedetectionio-${{ env.PYTHON_VERSION }}
path: /tmp/test-changedetectionio.tar
@@ -69,7 +69,7 @@ jobs:
- uses: actions/checkout@v6
- name: Download Docker image artifact
uses: actions/download-artifact@v6
uses: actions/download-artifact@v7
with:
name: test-changedetectionio-${{ env.PYTHON_VERSION }}
path: /tmp
@@ -96,7 +96,7 @@ jobs:
- uses: actions/checkout@v6
- name: Download Docker image artifact
uses: actions/download-artifact@v6
uses: actions/download-artifact@v7
with:
name: test-changedetectionio-${{ env.PYTHON_VERSION }}
path: /tmp
@@ -119,7 +119,7 @@ jobs:
- name: Store test artifacts
if: always()
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v6
with:
name: test-cdio-basic-tests-output-py${{ env.PYTHON_VERSION }}
path: output-logs
@@ -135,7 +135,7 @@ jobs:
- uses: actions/checkout@v6
- name: Download Docker image artifact
uses: actions/download-artifact@v6
uses: actions/download-artifact@v7
with:
name: test-changedetectionio-${{ env.PYTHON_VERSION }}
path: /tmp
@@ -177,7 +177,7 @@ jobs:
- uses: actions/checkout@v6
- name: Download Docker image artifact
uses: actions/download-artifact@v6
uses: actions/download-artifact@v7
with:
name: test-changedetectionio-${{ env.PYTHON_VERSION }}
path: /tmp
@@ -217,7 +217,7 @@ jobs:
- uses: actions/checkout@v6
- name: Download Docker image artifact
uses: actions/download-artifact@v6
uses: actions/download-artifact@v7
with:
name: test-changedetectionio-${{ env.PYTHON_VERSION }}
path: /tmp
@@ -253,7 +253,7 @@ jobs:
- uses: actions/checkout@v6
- name: Download Docker image artifact
uses: actions/download-artifact@v6
uses: actions/download-artifact@v7
with:
name: test-changedetectionio-${{ env.PYTHON_VERSION }}
path: /tmp
@@ -282,7 +282,7 @@ jobs:
- uses: actions/checkout@v6
- name: Download Docker image artifact
uses: actions/download-artifact@v6
uses: actions/download-artifact@v7
with:
name: test-changedetectionio-${{ env.PYTHON_VERSION }}
path: /tmp
@@ -322,7 +322,7 @@ jobs:
- uses: actions/checkout@v6
- name: Download Docker image artifact
uses: actions/download-artifact@v6
uses: actions/download-artifact@v7
with:
name: test-changedetectionio-${{ env.PYTHON_VERSION }}
path: /tmp
@@ -353,7 +353,7 @@ jobs:
- uses: actions/checkout@v6
- name: Download Docker image artifact
uses: actions/download-artifact@v6
uses: actions/download-artifact@v7
with:
name: test-changedetectionio-${{ env.PYTHON_VERSION }}
path: /tmp
@@ -398,7 +398,7 @@ jobs:
- uses: actions/checkout@v6
- name: Download Docker image artifact
uses: actions/download-artifact@v6
uses: actions/download-artifact@v7
with:
name: test-changedetectionio-${{ env.PYTHON_VERSION }}
path: /tmp
+4
View File
@@ -187,6 +187,10 @@ def main():
logger.critical(str(e))
return
# Inject datastore into plugins that need access to settings
from changedetectionio.pluggy_interface import inject_datastore_into_plugins
inject_datastore_into_plugins(datastore)
if default_url:
datastore.add_watch(url = default_url)
+111 -1
View File
@@ -3,7 +3,7 @@ import os
from changedetectionio.validate_url import is_safe_valid_url
from flask_expects_json import expects_json
from changedetectionio import queuedWatchMetaData
from changedetectionio import queuedWatchMetaData, strtobool
from changedetectionio import worker_handler
from flask_restful import abort, Resource
from flask import request, make_response, send_from_directory
@@ -12,6 +12,8 @@ import copy
# Import schemas from __init__.py
from . import schema, schema_create_watch, schema_update_watch, validate_openapi_request
from ..notification import valid_notification_formats
from ..notification.handler import newline_re
def validate_time_between_check_required(json_data):
@@ -181,6 +183,114 @@ class WatchSingleHistory(Resource):
return response
class WatchHistoryDiff(Resource):
"""
Generate diff between two historical snapshots.
Note: This API endpoint currently returns text-based diffs and works best
with the text_json_diff processor. Future processor types (like image_diff,
restock_diff) may want to implement their own specialized API endpoints
for returning processor-specific data (e.g., price charts, image comparisons).
The web UI diff page (/diff/<uuid>) is processor-aware and delegates rendering
to processors/{type}/difference.py::render() for processor-specific visualizations.
"""
def __init__(self, **kwargs):
# datastore is a black box dependency
self.datastore = kwargs['datastore']
@auth.check_token
@validate_openapi_request('getWatchHistoryDiff')
def get(self, uuid, from_timestamp, to_timestamp):
"""Generate diff between two historical snapshots."""
from changedetectionio import diff
from changedetectionio.notification.handler import apply_service_tweaks
watch = self.datastore.data['watching'].get(uuid)
if not watch:
abort(404, message=f"No watch exists with the UUID of {uuid}")
if not len(watch.history):
abort(404, message=f"Watch found but no history exists for the UUID {uuid}")
history_keys = list(watch.history.keys())
# Handle 'latest' keyword for to_timestamp
if to_timestamp == 'latest':
to_timestamp = history_keys[-1]
# Handle 'previous' keyword for from_timestamp (second-most-recent)
if from_timestamp == 'previous':
if len(history_keys) < 2:
abort(404, message=f"Not enough history entries. Need at least 2 snapshots for 'previous'")
from_timestamp = history_keys[-2]
# Validate timestamps exist
if from_timestamp not in watch.history:
abort(404, message=f"From timestamp {from_timestamp} not found in watch history")
if to_timestamp not in watch.history:
abort(404, message=f"To timestamp {to_timestamp} not found in watch history")
# Get the format parameter (default to 'text')
output_format = request.args.get('format', 'text').lower()
# Validate format
if output_format not in valid_notification_formats.keys():
abort(400, message=f"Invalid format. Must be one of: {', '.join(valid_notification_formats.keys())}")
# Get the word_diff parameter (default to False - line-level mode)
word_diff = strtobool(request.args.get('word_diff', 'false'))
# Get the no_markup parameter (default to False)
no_markup = strtobool(request.args.get('no_markup', 'false'))
# Retrieve snapshot contents
from_version_file_contents = watch.get_history_snapshot(from_timestamp)
to_version_file_contents = watch.get_history_snapshot(to_timestamp)
# Get diff preferences (using defaults similar to the existing code)
diff_prefs = {
'diff_ignoreWhitespace': False,
'diff_changesOnly': True
}
# Generate the diff
content = diff.render_diff(
previous_version_file_contents=from_version_file_contents,
newest_version_file_contents=to_version_file_contents,
ignore_junk=diff_prefs.get('diff_ignoreWhitespace'),
include_equal=not diff_prefs.get('diff_changesOnly'),
word_diff=word_diff,
)
# Skip formatting if no_markup is set
if no_markup:
mimetype = "text/plain"
else:
# Apply formatting based on the requested format
if output_format == 'htmlcolor':
from changedetectionio.notification.handler import apply_html_color_to_body
content = apply_html_color_to_body(n_body=content)
mimetype = "text/html"
else:
# Apply service tweaks for text/html formats
# Pass empty URL and title as they're not used for the placeholder replacement we need
_, content, _ = apply_service_tweaks(
url='',
n_body=content,
n_title='',
requested_output_format=output_format
)
mimetype = "text/html" if output_format == 'html' else "text/plain"
if 'html' in output_format:
content = newline_re.sub('<br>\r\n', content)
response = make_response(content, 200)
response.mimetype = mimetype
return response
class WatchFavicon(Resource):
def __init__(self, **kwargs):
# datastore is a black box dependency
+2 -2
View File
@@ -51,6 +51,7 @@ def validate_openapi_request(operation_id):
def decorator(f):
@functools.wraps(f)
def wrapper(*args, **kwargs):
from werkzeug.exceptions import BadRequest
try:
# Skip OpenAPI validation for GET requests since they don't have request bodies
if request.method.upper() != 'GET':
@@ -61,7 +62,6 @@ def validate_openapi_request(operation_id):
openapi_request = FlaskOpenAPIRequest(request)
result = spec.unmarshal_request(openapi_request)
if result.errors:
from werkzeug.exceptions import BadRequest
error_details = []
for error in result.errors:
error_details.append(str(error))
@@ -78,7 +78,7 @@ def validate_openapi_request(operation_id):
return decorator
# Import all API resources
from .Watch import Watch, WatchHistory, WatchSingleHistory, CreateWatch, WatchFavicon
from .Watch import Watch, WatchHistory, WatchSingleHistory, WatchHistoryDiff, CreateWatch, WatchFavicon
from .Tags import Tags, Tag
from .Import import Import
from .SystemInfo import SystemInfo
+6
View File
@@ -1,3 +1,5 @@
from blinker import signal
from .processors.exceptions import ProcessorException
import changedetectionio.content_fetchers.exceptions as content_fetchers_exceptions
from changedetectionio.processors.text_json_diff.processor import FilterNotFoundInResponse
@@ -97,6 +99,9 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore):
update_handler = processor_module.perform_site_check(datastore=datastore,
watch_uuid=uuid)
update_signal = signal('watch_small_status_comment')
update_signal.send(watch_uuid=uuid, status="Fetching page..")
# All fetchers are now async, so call directly
await update_handler.call_browser()
@@ -309,6 +314,7 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore):
if not datastore.data['watching'].get(uuid):
continue
logger.debug(f"Processing watch UUID: {uuid} - xpath_data length returned {len(update_handler.xpath_data) if update_handler.xpath_data else 'empty.'}")
if process_changedetection_results:
try:
datastore.update_watch(uuid=uuid, update_obj=update_obj)
+1 -1
View File
@@ -81,7 +81,7 @@ def construct_main_feed_routes(rss_blueprint, datastore):
timestamp_from = dates[-2]
guid = generate_watch_guid(watch, timestamp_to)
# Because we are called via whatever web server, flask should figure out the right path
diff_link = {'href': url_for('ui.ui_views.diff_history_page', uuid=watch['uuid'], _external=True)}
diff_link = {'href': url_for('ui.ui_diff.diff_history_page', uuid=watch['uuid'], _external=True)}
# Get template and build notification context
n_body_template = get_rss_template(datastore, watch, rss_content_format,
+1 -1
View File
@@ -67,7 +67,7 @@ def construct_tag_routes(rss_blueprint, datastore):
watch['uuid'] = uuid
# Include a link to the diff page
diff_link = {'href': url_for('ui.ui_views.diff_history_page', uuid=watch['uuid'], _external=True)}
diff_link = {'href': url_for('ui.ui_diff.diff_history_page', uuid=watch['uuid'], _external=True)}
# Get watch label
watch_label = get_watch_label(datastore, watch)
@@ -17,6 +17,12 @@ def construct_blueprint(datastore: ChangeDetectionStore):
@login_optionally_required
def settings_page():
from changedetectionio import forms
from changedetectionio.pluggy_interface import (
get_plugin_settings_tabs,
load_plugin_settings,
save_plugin_settings
)
default = deepcopy(datastore.data['settings'])
if datastore.proxy_list is not None:
@@ -102,6 +108,20 @@ def construct_blueprint(datastore: ChangeDetectionStore):
return redirect(url_for('watchlist.index'))
datastore.needs_write_urgent = True
# Also save plugin settings from the same form submission
plugin_tabs_list = get_plugin_settings_tabs()
for tab in plugin_tabs_list:
plugin_id = tab['plugin_id']
form_class = tab['form_class']
# Instantiate plugin form with POST data
plugin_form = form_class(formdata=request.form)
# Save plugin settings (validation is optional for plugins)
if plugin_form.data:
save_plugin_settings(datastore.datastore_path, plugin_id, plugin_form.data)
flash("Settings updated.")
else:
@@ -110,8 +130,30 @@ def construct_blueprint(datastore: ChangeDetectionStore):
# Convert to ISO 8601 format, all date/time relative events stored as UTC time
utc_time = datetime.now(ZoneInfo("UTC")).isoformat()
# Get active plugins
from changedetectionio.pluggy_interface import get_active_plugins
import sys
active_plugins = get_active_plugins()
python_version = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
# Get plugin settings tabs and instantiate forms
plugin_tabs = get_plugin_settings_tabs()
plugin_forms = {}
for tab in plugin_tabs:
plugin_id = tab['plugin_id']
form_class = tab['form_class']
# Load existing settings
settings = load_plugin_settings(datastore.datastore_path, plugin_id)
# Instantiate the form with existing settings
plugin_forms[plugin_id] = form_class(data=settings)
output = render_template("settings.html",
active_plugins=active_plugins,
api_key=datastore.data['settings']['application'].get('api_access_token'),
python_version=python_version,
available_timezones=sorted(available_timezones()),
emailprefix=os.getenv('NOTIFICATION_MAIL_BUTTON_PREFIX', False),
extra_notification_token_placeholder_info=datastore.get_unique_notification_token_placeholders_available(),
@@ -121,6 +163,8 @@ def construct_blueprint(datastore: ChangeDetectionStore):
settings_application=datastore.data['settings']['application'],
timezone_default_config=datastore.data['settings']['application'].get('scheduler_timezone_default'),
utc_time=utc_time,
plugin_tabs=plugin_tabs,
plugin_forms=plugin_forms,
)
return output
@@ -27,6 +27,12 @@
<li class="tab"><a href="#rss">RSS</a></li>
<li class="tab"><a href="#timedate">Time &amp Date</a></li>
<li class="tab"><a href="#proxies">CAPTCHA &amp; Proxies</a></li>
{% if plugin_tabs %}
{% for tab in plugin_tabs %}
<li class="tab"><a href="#plugin-{{ tab.plugin_id }}">{{ tab.tab_label }}</a></li>
{% endfor %}
{% endif %}
<li class="tab"><a href="#info">Info</a></li>
</ul>
</div>
<div class="box-wrap inner">
@@ -352,7 +358,45 @@ nav
</p>
{{ render_fieldlist_with_inline_errors(form.requests.form.extra_browsers) }}
</div>
</div>
{% if plugin_tabs %}
{% for tab in plugin_tabs %}
<div class="tab-pane-inner" id="plugin-{{ tab.plugin_id }}">
{% set plugin_form = plugin_forms[tab.plugin_id] %}
{% if tab.template_path %}
{# Plugin provides custom template - include it directly (no separate form) #}
{% include tab.template_path with context %}
{% else %}
{# Default form rendering - fields only, no submit button #}
<fieldset>
{% for field in plugin_form %}
{% if field.type != 'CSRFToken' and field.type != 'SubmitField' %}
<div class="pure-control-group">
{% if field.type == 'BooleanField' %}
{{ render_checkbox_field(field) }}
{% else %}
{{ render_field(field) }}
{% endif %}
</div>
{% endif %}
{% endfor %}
</fieldset>
{% endif %}
</div>
{% endfor %}
{% endif %}
<div class="tab-pane-inner" id="info">
<p><strong>Python version:</strong> {{ python_version }}</p>
<p><strong>Plugins active:</strong></p>
{% if active_plugins %}
<ul>
{% for plugin in active_plugins %}
<li><strong>{{ plugin.name }}</strong> - {{ plugin.description }}</li>
{% endfor %}
</ul>
{% else %}
<p>No plugins active</p>
{% endif %}
</div>
<div id="actions">
<div class="pure-control-group">
+9 -1
View File
@@ -6,6 +6,7 @@ from changedetectionio.store import ChangeDetectionStore
from changedetectionio.blueprint.ui.edit import construct_blueprint as construct_edit_blueprint
from changedetectionio.blueprint.ui.notification import construct_blueprint as construct_notification_blueprint
from changedetectionio.blueprint.ui.views import construct_blueprint as construct_views_blueprint
from changedetectionio.blueprint.ui import diff, preview
def _handle_operations(op, uuids, datastore, worker_handler, update_q, queuedWatchMetaData, watch_check_update, extra_data=None, emit_flash=True):
from flask import request, flash
@@ -121,6 +122,13 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, worker_handle
views_blueprint = construct_views_blueprint(datastore, update_q, queuedWatchMetaData, watch_check_update)
ui_blueprint.register_blueprint(views_blueprint)
# Register diff and preview blueprints
diff_blueprint = diff.construct_blueprint(datastore)
ui_blueprint.register_blueprint(diff_blueprint)
preview_blueprint = preview.construct_blueprint(datastore)
ui_blueprint.register_blueprint(preview_blueprint)
# Import the login decorator
from changedetectionio.auth_decorator import login_optionally_required
@@ -249,7 +257,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, worker_handle
if i == 0:
flash("No watches available to recheck.")
return redirect(url_for('watchlist.index'))
return redirect(url_for('watchlist.index', **({'tag': tag} if tag else {})))
@ui_blueprint.route("/form/checkbox-operations", methods=['POST'])
@login_optionally_required
+243
View File
@@ -0,0 +1,243 @@
from flask import Blueprint, request, redirect, url_for, flash, render_template, make_response, send_from_directory
import os
import time
import re
import importlib
from loguru import logger
from markupsafe import Markup
from changedetectionio.diff import (
REMOVED_STYLE, ADDED_STYLE, REMOVED_INNER_STYLE, ADDED_INNER_STYLE,
REMOVED_PLACEMARKER_OPEN, REMOVED_PLACEMARKER_CLOSED,
ADDED_PLACEMARKER_OPEN, ADDED_PLACEMARKER_CLOSED,
CHANGED_PLACEMARKER_OPEN, CHANGED_PLACEMARKER_CLOSED,
CHANGED_INTO_PLACEMARKER_OPEN, CHANGED_INTO_PLACEMARKER_CLOSED
)
from changedetectionio.store import ChangeDetectionStore
from changedetectionio.auth_decorator import login_optionally_required
def construct_blueprint(datastore: ChangeDetectionStore):
diff_blueprint = Blueprint('ui_diff', __name__, template_folder="../ui/templates")
@diff_blueprint.app_template_filter('diff_unescape_difference_spans')
def diff_unescape_difference_spans(content):
"""Emulate Jinja2's auto-escape, then selectively unescape our diff spans."""
from markupsafe import escape
if not content:
return Markup('')
# Step 1: Escape everything like Jinja2 would (this makes it XSS-safe)
escaped_content = escape(str(content))
# Step 2: Unescape only our exact diff spans generated by apply_html_color_to_body()
# Pattern matches the exact structure:
# <span style="{STYLE}" role="{ROLE}" aria-label="{LABEL}" title="{TITLE}">
# Unescape outer span opening tags with full attributes (role, aria-label, title)
# Matches removed/added/changed/changed_into spans
result = re.sub(
rf'&lt;span style=&#34;({re.escape(REMOVED_STYLE)}|{re.escape(ADDED_STYLE)})&#34; '
rf'role=&#34;(deletion|insertion|note)&#34; '
rf'aria-label=&#34;([^&]+?)&#34; '
rf'title=&#34;([^&]+?)&#34;&gt;',
r'<span style="\1" role="\2" aria-label="\3" title="\4">',
str(escaped_content),
flags=re.IGNORECASE
)
# Unescape inner span opening tags (without additional attributes)
# This matches the darker background styles for changed parts within lines
result = re.sub(
rf'&lt;span style=&#34;({re.escape(REMOVED_INNER_STYLE)}|{re.escape(ADDED_INNER_STYLE)})&#34;&gt;',
r'<span style="\1">',
result,
flags=re.IGNORECASE
)
# Unescape closing tags (but only as many as we opened)
open_count = result.count('<span style=')
close_count = str(escaped_content).count('&lt;/span&gt;')
# Replace up to the number of spans we opened
for _ in range(min(open_count, close_count)):
result = result.replace('&lt;/span&gt;', '</span>', 1)
return Markup(result)
@diff_blueprint.route("/diff/<string:uuid>", methods=['GET'])
@login_optionally_required
def diff_history_page(uuid):
"""
Render the history/diff page for a watch.
This route is processor-aware: it delegates rendering to the processor's
difference.py module, allowing different processor types to provide
custom visualizations:
- text_json_diff: Text/HTML diff with syntax highlighting
- restock_diff: Could show price charts and stock history
- image_diff: Could show image comparison slider/overlay
Each processor implements processors/{type}/difference.py::render()
If a processor doesn't have a difference module, falls back to text_json_diff.
"""
# More for testing, possible to return the first/only
if uuid == 'first':
uuid = list(datastore.data['watching'].keys()).pop()
try:
watch = datastore.data['watching'][uuid]
except KeyError:
flash("No history found for the specified link, bad link?", "error")
return redirect(url_for('watchlist.index'))
# Get the processor type for this watch
processor_name = watch.get('processor', 'text_json_diff')
try:
# Try to import the processor's difference module
processor_module = importlib.import_module(f'changedetectionio.processors.{processor_name}.difference')
# Call the processor's render() function
if hasattr(processor_module, 'render'):
return processor_module.render(
watch=watch,
datastore=datastore,
request=request,
url_for=url_for,
render_template=render_template,
flash=flash,
redirect=redirect
)
except (ImportError, ModuleNotFoundError) as e:
logger.warning(f"Processor {processor_name} does not have a difference module, falling back to text_json_diff: {e}")
# Fallback: if processor doesn't have difference module, use text_json_diff as default
from changedetectionio.processors.text_json_diff.difference import render as default_render
return default_render(
watch=watch,
datastore=datastore,
request=request,
url_for=url_for,
render_template=render_template,
flash=flash,
redirect=redirect
)
@diff_blueprint.route("/diff/<string:uuid>/extract", methods=['GET'])
@login_optionally_required
def diff_history_page_extract_GET(uuid):
"""
Render the data extraction form for a watch.
This route is processor-aware: it delegates to the processor's
extract.py module, allowing different processor types to provide
custom extraction interfaces.
Each processor implements processors/{type}/extract.py::render_form()
If a processor doesn't have an extract module, falls back to text_json_diff.
"""
# More for testing, possible to return the first/only
if uuid == 'first':
uuid = list(datastore.data['watching'].keys()).pop()
try:
watch = datastore.data['watching'][uuid]
except KeyError:
flash("No history found for the specified link, bad link?", "error")
return redirect(url_for('watchlist.index'))
# Get the processor type for this watch
processor_name = watch.get('processor', 'text_json_diff')
try:
# Try to import the processor's extract module
processor_module = importlib.import_module(f'changedetectionio.processors.{processor_name}.extract')
# Call the processor's render_form() function
if hasattr(processor_module, 'render_form'):
return processor_module.render_form(
watch=watch,
datastore=datastore,
request=request,
url_for=url_for,
render_template=render_template,
flash=flash,
redirect=redirect
)
except (ImportError, ModuleNotFoundError) as e:
logger.warning(f"Processor {processor_name} does not have an extract module, falling back to base extractor: {e}")
# Fallback: if processor doesn't have extract module, use base processors.extract as default
from changedetectionio.processors.extract import render_form as default_render_form
return default_render_form(
watch=watch,
datastore=datastore,
request=request,
url_for=url_for,
render_template=render_template,
flash=flash,
redirect=redirect
)
@diff_blueprint.route("/diff/<string:uuid>/extract", methods=['POST'])
@login_optionally_required
def diff_history_page_extract_POST(uuid):
"""
Process the data extraction request.
This route is processor-aware: it delegates to the processor's
extract.py module, allowing different processor types to provide
custom extraction logic.
Each processor implements processors/{type}/extract.py::process_extraction()
If a processor doesn't have an extract module, falls back to text_json_diff.
"""
# More for testing, possible to return the first/only
if uuid == 'first':
uuid = list(datastore.data['watching'].keys()).pop()
try:
watch = datastore.data['watching'][uuid]
except KeyError:
flash("No history found for the specified link, bad link?", "error")
return redirect(url_for('watchlist.index'))
# Get the processor type for this watch
processor_name = watch.get('processor', 'text_json_diff')
try:
# Try to import the processor's extract module
processor_module = importlib.import_module(f'changedetectionio.processors.{processor_name}.extract')
# Call the processor's process_extraction() function
if hasattr(processor_module, 'process_extraction'):
return processor_module.process_extraction(
watch=watch,
datastore=datastore,
request=request,
url_for=url_for,
make_response=make_response,
send_from_directory=send_from_directory,
flash=flash,
redirect=redirect
)
except (ImportError, ModuleNotFoundError) as e:
logger.warning(f"Processor {processor_name} does not have an extract module, falling back to base extractor: {e}")
# Fallback: if processor doesn't have extract module, use base processors.extract as default
from changedetectionio.processors.extract import process_extraction as default_process_extraction
return default_process_extraction(
watch=watch,
datastore=datastore,
request=request,
url_for=url_for,
make_response=make_response,
send_from_directory=send_from_directory,
flash=flash,
redirect=redirect
)
return diff_blueprint
+7 -13
View File
@@ -206,7 +206,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
# Diff page [edit] link should go back to diff page
if request.args.get("next") and request.args.get("next") == 'diff':
return redirect(url_for('ui.ui_views.diff_history_page', uuid=uuid))
return redirect(url_for('ui.ui_diff.diff_history_page', uuid=uuid))
return redirect(url_for('watchlist.index', tag=request.args.get("tag",'')))
@@ -223,19 +223,13 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
watch = datastore.data['watching'].get(uuid)
# if system or watch is configured to need a chrome type browser
system_uses_webdriver = datastore.data['settings']['application']['fetch_backend'] == 'html_webdriver'
watch_needs_selenium_or_playwright = False
if (watch.get('fetch_backend') == 'system' and system_uses_webdriver) or watch.get('fetch_backend') == 'html_webdriver' or watch.get('fetch_backend', '').startswith('extra_browser_'):
watch_needs_selenium_or_playwright = True
from zoneinfo import available_timezones
# Only works reliably with Playwright
# Import the global plugin system
from changedetectionio.pluggy_interface import collect_ui_edit_stats_extras
from changedetectionio.pluggy_interface import collect_ui_edit_stats_extras, get_fetcher_capabilities
# Get fetcher capabilities instead of hardcoded logic
capabilities = get_fetcher_capabilities(watch, datastore)
app_rss_token = datastore.data['settings']['application'].get('rss_access_token'),
template_args = {
'available_processors': processors.available_processors(),
@@ -266,7 +260,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
'using_global_webdriver_wait': not default['webdriver_delay'],
'uuid': uuid,
'watch': watch,
'watch_needs_selenium_or_playwright': watch_needs_selenium_or_playwright,
'capabilities': capabilities
}
included_content = None
@@ -340,6 +334,6 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
s = re.sub(r'[0-9]+', r'\\d+', s)
datastore.data["watching"][uuid]['ignore_text'].append('/' + s + '/')
return f"<a href={url_for('ui.ui_views.preview_page', uuid=uuid)}>Click to preview</a>"
return f"<a href={url_for('ui.ui_preview.preview_page', uuid=uuid)}>Click to preview</a>"
return edit_blueprint
@@ -108,8 +108,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
prev_snapshot = watch.get_history_snapshot(timestamp=dates[-2])
current_snapshot = watch.get_history_snapshot(timestamp=dates[-1])
n_object.update(set_basic_notification_vars(snapshot_contents=snapshot_contents,
current_snapshot=current_snapshot,
n_object.update(set_basic_notification_vars(current_snapshot=current_snapshot,
prev_snapshot=prev_snapshot,
watch=watch,
triggered_text=trigger_text,
+95
View File
@@ -0,0 +1,95 @@
from flask import Blueprint, request, url_for, flash, render_template, redirect
import time
from loguru import logger
from changedetectionio.store import ChangeDetectionStore
from changedetectionio.auth_decorator import login_optionally_required
from changedetectionio import html_tools
def construct_blueprint(datastore: ChangeDetectionStore):
preview_blueprint = Blueprint('ui_preview', __name__, template_folder="../ui/templates")
@preview_blueprint.route("/preview/<string:uuid>", methods=['GET'])
@login_optionally_required
def preview_page(uuid):
content = []
versions = []
timestamp = None
# More for testing, possible to return the first/only
if uuid == 'first':
uuid = list(datastore.data['watching'].keys()).pop()
try:
watch = datastore.data['watching'][uuid]
except KeyError:
flash("No history found for the specified link, bad link?", "error")
return redirect(url_for('watchlist.index'))
system_uses_webdriver = datastore.data['settings']['application']['fetch_backend'] == 'html_webdriver'
extra_stylesheets = [url_for('static_content', group='styles', filename='diff.css')]
is_html_webdriver = False
if (watch.get('fetch_backend') == 'system' and system_uses_webdriver) or watch.get('fetch_backend') == 'html_webdriver' or watch.get('fetch_backend', '').startswith('extra_browser_'):
is_html_webdriver = True
triggered_line_numbers = []
ignored_line_numbers = []
blocked_line_numbers = []
if datastore.data['watching'][uuid].history_n == 0 and (watch.get_error_text() or watch.get_error_snapshot()):
flash("Preview unavailable - No fetch/check completed or triggers not reached", "error")
else:
# So prepare the latest preview or not
preferred_version = request.args.get('version')
versions = list(watch.history.keys())
timestamp = versions[-1]
if preferred_version and preferred_version in versions:
timestamp = preferred_version
try:
versions = list(watch.history.keys())
content = watch.get_history_snapshot(timestamp=timestamp)
triggered_line_numbers = html_tools.strip_ignore_text(content=content,
wordlist=watch.get('trigger_text'),
mode='line numbers'
)
ignored_line_numbers = html_tools.strip_ignore_text(content=content,
wordlist=watch.get('ignore_text'),
mode='line numbers'
)
blocked_line_numbers = html_tools.strip_ignore_text(content=content,
wordlist=watch.get("text_should_not_be_present"),
mode='line numbers'
)
except Exception as e:
content.append({'line': f"File doesnt exist or unable to read timestamp {timestamp}", 'classes': ''})
from changedetectionio.pluggy_interface import get_fetcher_capabilities
capabilities = get_fetcher_capabilities(watch, datastore)
output = render_template("preview.html",
capabilities=capabilities,
content=content,
current_diff_url=watch['url'],
current_version=timestamp,
extra_stylesheets=extra_stylesheets,
extra_title=f" - Diff - {watch.label} @ {timestamp}",
highlight_ignored_line_numbers=ignored_line_numbers,
highlight_triggered_line_numbers=triggered_line_numbers,
highlight_blocked_line_numbers=blocked_line_numbers,
history_n=watch.history_n,
is_html_webdriver=is_html_webdriver,
last_error=watch['last_error'],
last_error_screenshot=watch.get_error_snapshot(),
last_error_text=watch.get_error_text(),
screenshot=watch.get_screenshot(),
uuid=uuid,
versions=versions,
watch=watch,
)
return output
return preview_blueprint
@@ -0,0 +1,12 @@
<ul id="highlightSnippetActions">
<li>
<button class="pure-button pure-button-primary" onclick="diffToJpeg()" title="Share diff as image">Share as Image</button>
</li>
<li>
<a class="pure-button pure-button-primary" data-mode="exact" href="javascript:void(0);">Ignore any lines matching</a>
</li>
<li>
<a class="pure-button pure-button-primary" data-mode="digit-regex" href="javascript:void(0);" >Ignore any lines matching excluding digits</a>
</li>
</ul>
@@ -0,0 +1,166 @@
{% extends 'base.html' %}
{% from '_helpers.html' import render_field, render_checkbox_field, render_button %}
{% block content %}
<script>
const screenshot_url="{{url_for('static_content', group='screenshot', filename=uuid)}}";
{% if last_error_screenshot %}
const error_screenshot_url="{{url_for('static_content', group='screenshot', filename=uuid, error_screenshot=1) }}";
{% endif %}
const highlight_submit_ignore_url="{{url_for('ui.ui_edit.highlight_submit_ignore_url', uuid=uuid)}}";
const watch_url= {{watch_a.link|tojson}};
// Initial scroll position: if set, scroll to this line number in #difference on page load
const initialScrollToLineNumber = {{ initial_scroll_line_number|default('null') }};
</script>
<script src="https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js"></script>
<script src="{{url_for('static_content', group='js', filename='plugins.js')}}"></script>
<script src="https://cdn.jsdelivr.net/npm/piexifjs@1.0.6/piexif.min.js"></script>
<script src="{{url_for('static_content', group='js', filename='snippet-to-image.js')}}"></script>
<script src="{{url_for('static_content', group='js', filename='diff-overview.js')}}" defer></script>
<div id="settings">
<form class="pure-form " action="{{ url_for("ui.ui_diff.diff_history_page", uuid=uuid) }}" method="GET" id="diff-form">
<fieldset class="diff-fieldset">
{% if versions|length >= 1 %}
<span style="white-space: nowrap;">
<label id="change-from" for="diff-from-version" class="from-to-label">From</label>
<select id="diff-from-version" name="from_version" class="needs-localtime">
{%- for version in versions|reverse -%}
<option value="{{ version }}" {% if version== from_version %} selected="" {% endif %}>
{{ version }}{#{% if loop.index == 2 %} (Previous){% endif %}#}
</option>
{%- endfor -%}
</select>
</span>
<span style="white-space: nowrap;">
<label id="change-to" for="diff-to-version" class="from-to-label">To</label>
<select id="diff-to-version" name="to_version" class="needs-localtime">
{%- for version in versions|reverse -%}
<option value="{{ version }}" {% if version== to_version %} selected="" {% endif %}>
{{ version }}{#{% if loop.first %} (Current){% endif %}#}
</option>
{%- endfor -%}
</select>
</span>
{#<button type="submit" class="pure-button pure-button-primary reset-margin">Go</button>#}
{% endif %}
</fieldset>
<fieldset id="diff-style">
<span>
<label for="diffWords" class="pure-checkbox">
<input type="radio" name="type" id="diffWords" value="diffWords" {% if diff_prefs.type == 'diffWords' %}checked=""{% endif %}> Words</label>
</span>
<span>
<label for="diffLines" class="pure-checkbox">
<input type="radio" name="type" id="diffLines" value="diffLines" {% if diff_prefs.type == 'diffLines' %}checked=""{% endif %}> Lines</label>
</span>
<span>
<label for="ignoreWhitespace" class="pure-checkbox" id="label-diff-ignorewhitespace">
<input type="checkbox" id="ignoreWhitespace" name="ignoreWhitespace" {% if diff_prefs.ignoreWhitespace %}checked=""{% endif %}> Ignore Whitespace</label>
</span>
<span>
<label for="changesOnly" class="pure-checkbox" id="label-diff-changes">
<input type="checkbox" id="changesOnly" name="changesOnly" {% if diff_prefs.changesOnly %}checked=""{% endif %}> Same/non-changed</label>
</span>
<span>
<label for="removed" class="pure-checkbox" id="label-diff-removed">
<input type="checkbox" id="removed" name="removed" {% if diff_prefs.removed %}checked=""{% endif %}> Removed</label>
</span>
<span>
<label for="added" class="pure-checkbox" id="label-diff-added">
<input type="checkbox" id="added" name="added" {% if diff_prefs.added %}checked=""{% endif %}> Added</label>
</span>
<span>
<label for="replaced" class="pure-checkbox" id="label-diff-replaced">
<input type="checkbox" id="replaced" name="replaced" {% if diff_prefs.replaced %}checked=""{% endif %}> Replaced</label>
</span>
</fieldset>
{%- if versions|length >= 2 -%}
<div id="keyboard-nav">
<strong>Keyboard: </strong>
<a href="" class="pure-button pure-button-primary" id="btn-previous"> &larr; Previous</a>
&nbsp; <a class="pure-button pure-button-primary" id="btn-next" href=""> &rarr; Next</a>
</div>
{%- endif -%}
</form>
</div>
<div id="diff-jump">
<a id="jump-next-diff" title="Jump to next difference">Jump</a>
</div>
<script src="{{url_for('static_content', group='js', filename='tabs.js')}}" defer></script>
<div class="tabs">
<ul>
{% if last_error_text %}<li class="tab" id="error-text-tab"><a href="#error-text">Error Text</a></li> {% endif %}
{% if last_error_screenshot %}<li class="tab" id="error-screenshot-tab"><a href="#error-screenshot">Error Screenshot</a></li> {% endif %}
<li class="tab" id=""><a href="#text">Text</a></li>
<li class="tab" id="screenshot-tab"><a href="#screenshot">Screenshot</a></li>
<li class="tab" id="extract-tab"><a href="{{ url_for('ui.ui_diff.diff_history_page_extract_GET', uuid=uuid)}}">Extract Data</a></li>
</ul>
</div>
<div id="diff-ui">
<div class="tab-pane-inner" id="error-text">
<div class="snapshot-age error">{{watch_a.error_text_ctime|format_seconds_ago}} seconds ago.</div>
<pre>
{{ last_error_text }}
</pre>
</div>
<div class="tab-pane-inner" id="error-screenshot">
<div class="snapshot-age error">{{watch_a.snapshot_error_screenshot_ctime|format_seconds_ago}} seconds ago</div>
<img id="error-screenshot-img" style="max-width: 80%" alt="Current error-ing screenshot from most recent request" >
</div>
<div class="tab-pane-inner" id="text">
{%- if (content | default('')).split('\n') | length > 100 -%}
<div id="cell-diff-jump-visualiser" style="user-select: none;">
{%- for cell in diff_cell_grid -%}
<div{% if cell.class %} class="{{ cell.class }}"{% endif %}></div>
{%- endfor -%}
</div>
{%- endif -%}
{%- if password_enabled_and_share_is_off -%}
<div class="tip">Pro-tip: You can enable <strong>"share access when password is enabled"</strong> from settings.
</div>
{%- endif -%}
<div id="text-diff-heading-area" style="user-select: none;">
<div class="snapshot-age"><span>{{ from_version|format_timestamp_timeago }}</span>
{%- if note -%}<span class="note"><strong>{{ note }}</strong></span>{%- endif -%}
<a href="{{ url_for("ui.ui_preview.preview_page", uuid=uuid) }}">Goto single snapshot</a>
</div>
</div>
<pre id="difference" style="border-left: 2px solid #ddd;">{{ content| diff_unescape_difference_spans }}</pre>
<div id="diff-visualiser-area-after" style="user-select: none;">
<strong>Tip:</strong> Highlight text to share or add to ignore lists.
</div>
</div>
<div class="tab-pane-inner" id="screenshot">
<div class="tip">
For now, Differences are performed on text, not graphically, only the latest screenshot is available.
</div>
{% if is_html_webdriver %}
{% if screenshot %}
<div class="snapshot-age">{{watch_a.snapshot_screenshot_ctime|format_timestamp_timeago}}</div>
<img style="max-width: 80%" id="screenshot-img" alt="Current screenshot from most recent request" >
{% else %}
No screenshot available just yet! Try rechecking the page.
{% endif %}
{% else %}
<strong>Screenshot requires Playwright/WebDriver enabled</strong>
{% endif %}
</div>
</div>
<script>
const newest_version_timestamp = {{newest_version_timestamp}};
</script>
<script src="{{url_for('static_content', group='js', filename='diff-render.js')}}"></script>
{% endblock %}
@@ -1,6 +1,6 @@
{% extends 'base.html' %}
{% block content %}
{% from '_helpers.html' import render_field, render_checkbox_field, render_button, render_time_schedule_form, playwright_warning, only_playwright_type_watches_warning, render_conditions_fieldlist_of_formfields_as_table, render_ternary_field %}
{% from '_helpers.html' import render_field, render_checkbox_field, render_button, render_time_schedule_form, playwright_warning, only_playwright_type_watches_warning, highlight_trigger_ignored_explainer, render_conditions_fieldlist_of_formfields_as_table, render_ternary_field %}
{% from '_common_fields.html' import render_common_settings_form %}
<script src="{{url_for('static_content', group='js', filename='tabs.js')}}" defer></script>
<script src="{{url_for('static_content', group='js', filename='vis.js')}}" defer></script>
@@ -206,9 +206,8 @@ Math: {{ 1 + 1 }}") }}
</div>
<div class="tab-pane-inner" id="browser-steps">
{% if watch_needs_selenium_or_playwright %}
{# Only works with playwright #}
{% if system_has_playwright_configured %}
{% if capabilities.supports_browser_steps %}
{% if visual_selector_data_ready %}
<img class="beta-logo" src="{{url_for('static_content', group='images', filename='beta-logo.png')}}" alt="New beta functionality">
<fieldset>
<div class="pure-control-group">
@@ -248,15 +247,13 @@ Math: {{ 1 + 1 }}") }}
</div>
</fieldset>
{% else %}
{# it's configured to use selenium or chrome but system says its not configured #}
{{ playwright_warning() }}
{% if system_has_webdriver_configured %}
<strong>Selenium/Webdriver cant be used here because it wont fetch screenshots reliably.</strong>
{% endif %}
<strong>Visual Selector data is not ready, watch needs to be checked atleast once.</strong>
{% endif %}
{% else %}
{# "This functionality needs chrome.." #}
{{ only_playwright_type_watches_warning() }}
<p>
<strong>Sorry, this functionality only works with fetchers that support interactive Javascript (so far only Playwright based fetchers)<br>
You need to <a href="#request">Set the fetch method</a> to one that supports interactive Javascript.</strong>
</p>
{% endif %}
</div>
@@ -266,7 +263,7 @@ Math: {{ 1 + 1 }}") }}
<div class="pure-control-group inline-radio">
{{ render_ternary_field(form.notification_muted, BooleanField=true) }}
</div>
{% if watch_needs_selenium_or_playwright %}
{% if capabilities.supports_screenshots %}
<div class="pure-control-group inline-radio">
{{ render_checkbox_field(form.notification_screenshot) }}
<span class="pure-form-message-inline">
@@ -351,21 +348,22 @@ Math: {{ 1 + 1 }}") }}
</div>
</div>
<div id="text-preview" style="display: none;" >
<script>
const preview_text_edit_filters_url="{{url_for('ui.ui_edit.watch_get_preview_rendered', uuid=uuid)}}";
</script>
<br>
{#<div id="text-preview-controls"><span id="text-preview-refresh" class="pure-button button-xsmall">Refresh</span></div>#}
<div class="minitabs-wrapper">
<div class="minitabs-content">
<div id="text-preview-inner" class="monospace-preview">
<p>Loading...</p>
</div>
<div id="text-preview-before-inner" style="display: none;" class="monospace-preview">
<p>Loading...</p>
</div>
<script>
const preview_text_edit_filters_url="{{url_for('ui.ui_edit.watch_get_preview_rendered', uuid=uuid)}}";
</script>
<br>
{#<div id="text-preview-controls"><span id="text-preview-refresh" class="pure-button button-xsmall">Refresh</span></div>#}
<div class="minitabs-wrapper">
<div class="minitabs-content">
<div id="text-preview-inner" class="monospace-preview">
<p>Loading...</p>
</div>
</div>
<div id="text-preview-before-inner" style="display: none;" class="monospace-preview">
<p>Loading...</p>
</div>
</div>
</div>
{{ highlight_trigger_ignored_explainer() }}
</div>
</div>
</div>
@@ -383,35 +381,33 @@ Math: {{ 1 + 1 }}") }}
<fieldset>
<div class="pure-control-group">
{% if watch_needs_selenium_or_playwright %}
{% if system_has_playwright_configured %}
<span class="pure-form-message-inline" id="visual-selector-heading">
The Visual Selector tool lets you select the <i>text</i> elements that will be used for the change detection. It automatically fills-in the filters in the "CSS/JSONPath/JQ/XPath Filters" box of the <a href="#filters-and-triggers">Filters & Triggers</a> tab. Use <strong>Shift+Click</strong> to select multiple items.
</span>
{% if capabilities.supports_screenshots and capabilities.supports_xpath_element_data %}
{% if visual_selector_data_ready %}
<span class="pure-form-message-inline" id="visual-selector-heading">
The Visual Selector tool lets you select the <i>text</i> elements that will be used for the change detection. It automatically fills-in the filters in the "CSS/JSONPath/JQ/XPath Filters" box of the <a href="#filters-and-triggers">Filters & Triggers</a> tab. Use <strong>Shift+Click</strong> to select multiple items.
</span>
<div id="selector-header">
<a id="clear-selector" class="pure-button button-secondary button-xsmall" style="font-size: 70%">Clear selection</a>
<!-- visual selector IMG will try to load, it will either replace this or on error replace it with some handy text -->
<i class="fetching-update-notice" style="font-size: 80%;">One moment, fetching screenshot and element information..</i>
</div>
<div id="selector-wrapper" style="display: none">
<!-- request the screenshot and get the element offset info ready -->
<!-- use img src ready load to know everything is ready to map out -->
<!-- @todo: maybe something interesting like a field to select 'elements that contain text... and their parents n' -->
<img id="selector-background" >
<canvas id="selector-canvas"></canvas>
</div>
<div id="selector-current-xpath" style="overflow-x: hidden"><strong>Currently:</strong>&nbsp;<span class="text">Loading...</span></div>
{% else %}
{# The watch needed chrome but system says that playwright is not ready #}
{{ playwright_warning() }}
{% endif %}
{% if system_has_webdriver_configured %}
<strong>Selenium/Webdriver cant be used here because it wont fetch screenshots reliably.</strong>
{% endif %}
<div id="selector-header">
<a id="clear-selector" class="pure-button button-secondary button-xsmall" style="font-size: 70%">Clear selection</a>
<!-- visual selector IMG will try to load, it will either replace this or on error replace it with some handy text -->
<i class="fetching-update-notice" style="font-size: 80%;">One moment, fetching screenshot and element information..</i>
</div>
<div id="selector-wrapper" style="display: none">
<!-- request the screenshot and get the element offset info ready -->
<!-- use img src ready load to know everything is ready to map out -->
<!-- @todo: maybe something interesting like a field to select 'elements that contain text... and their parents n' -->
<img id="selector-background" >
<canvas id="selector-canvas"></canvas>
</div>
<div id="selector-current-xpath" style="overflow-x: hidden"><strong>Currently:</strong>&nbsp;<span class="text">Loading...</span></div>
{% else %}
<strong>Visual Selector data is not ready, watch needs to be checked atleast once.</strong>
{% endif %}
{% else %}
{# "This functionality needs chrome.." #}
{{ only_playwright_type_watches_warning() }}
<p>
<strong>Sorry, this functionality only works with fetchers that support Javascript and screenshots (such as playwright etc).<br>
You need to <a href="#request">Set the fetch method</a> to one that supports Javascript and screenshots.</strong>
</p>
{% endif %}
</div>
</fieldset>
@@ -1,9 +1,11 @@
{% extends 'base.html' %}
{% from '_helpers.html' import highlight_trigger_ignored_explainer %}
{% block content %}
<script>
const screenshot_url = "{{url_for('static_content', group='screenshot', filename=uuid)}}";
const triggered_line_numbers = {{ triggered_line_numbers|tojson }};
const triggered_line_numbers = {{ highlight_triggered_line_numbers|tojson }};
const ignored_line_numbers = {{ highlight_ignored_line_numbers|tojson }};
const blocked_line_numbers = {{ highlight_blocked_line_numbers|tojson }};
{% if last_error_screenshot %}
const error_screenshot_url = "{{url_for('static_content', group='screenshot', filename=uuid, error_screenshot=1) }}";
{% endif %}
@@ -14,7 +16,7 @@
<script src="{{ url_for('static_content', group='js', filename='preview.js') }}" defer></script>
<script src="{{ url_for('static_content', group='js', filename='tabs.js') }}" defer></script>
{% if versions|length >= 2 %}
<div id="settings" style="text-align: center;">
<div id="diff-form" style="text-align: center;">
<form class="pure-form " action="" method="POST">
<fieldset>
<label for="preview-version">Select timestamp</label> <select id="preview-version"
@@ -68,28 +70,19 @@
</div>
<div class="tab-pane-inner" id="text">
{{ highlight_trigger_ignored_explainer() }}
<div class="snapshot-age">{{ current_version|format_timestamp_timeago }}</div>
<span class="tip"><strong>Pro-tip</strong>: Highlight text to add to ignore filters</span>
<table>
<tbody>
<tr>
<td id="diff-col" class="highlightable-filter">
<pre style="border-left: 2px solid #ddd;">
{{ content }}
</pre>
</td>
</tr>
</tbody>
</table>
<pre id="difference" style="border-left: 2px solid #ddd;">{{ content| diff_unescape_difference_spans }}</pre>
</div>
</div>
<div class="tab-pane-inner" id="screenshot">
<div class="tip">
For now, Differences are performed on text, not graphically, only the latest screenshot is available.
</div>
<br>
{% if is_html_webdriver %}
{% if capabilities.supports_screenshots %}
{% if screenshot %}
<div class="snapshot-age">{{ watch.snapshot_screenshot_ctime|format_timestamp_timeago }}</div>
<img style="max-width: 80%" id="screenshot-img" alt="Current screenshot from most recent request">
@@ -97,7 +90,7 @@
No screenshot available just yet! Try rechecking the page.
{% endif %}
{% else %}
<strong>Screenshot requires Playwright/WebDriver enabled</strong>
<strong>Screenshot requires a Content Fetcher ( Chrome, Zyte etc ) that supports screenshots.</strong>
{% endif %}
</div>
</div>
+2 -198
View File
@@ -1,207 +1,11 @@
from flask import Blueprint, request, redirect, url_for, flash, render_template, make_response, send_from_directory, abort
import os
import time
from loguru import logger
from flask import Blueprint, request, redirect, url_for, flash
from changedetectionio.store import ChangeDetectionStore
from changedetectionio.auth_decorator import login_optionally_required
from changedetectionio import html_tools
from changedetectionio import worker_handler
def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMetaData, watch_check_update):
views_blueprint = Blueprint('ui_views', __name__, template_folder="../ui/templates")
@views_blueprint.route("/preview/<string:uuid>", methods=['GET'])
@login_optionally_required
def preview_page(uuid):
content = []
versions = []
timestamp = None
# More for testing, possible to return the first/only
if uuid == 'first':
uuid = list(datastore.data['watching'].keys()).pop()
try:
watch = datastore.data['watching'][uuid]
except KeyError:
flash("No history found for the specified link, bad link?", "error")
return redirect(url_for('watchlist.index'))
system_uses_webdriver = datastore.data['settings']['application']['fetch_backend'] == 'html_webdriver'
extra_stylesheets = [url_for('static_content', group='styles', filename='diff.css')]
is_html_webdriver = False
if (watch.get('fetch_backend') == 'system' and system_uses_webdriver) or watch.get('fetch_backend') == 'html_webdriver' or watch.get('fetch_backend', '').startswith('extra_browser_'):
is_html_webdriver = True
triggered_line_numbers = []
if datastore.data['watching'][uuid].history_n == 0 and (watch.get_error_text() or watch.get_error_snapshot()):
flash("Preview unavailable - No fetch/check completed or triggers not reached", "error")
else:
# So prepare the latest preview or not
preferred_version = request.args.get('version')
versions = list(watch.history.keys())
timestamp = versions[-1]
if preferred_version and preferred_version in versions:
timestamp = preferred_version
try:
versions = list(watch.history.keys())
content = watch.get_history_snapshot(timestamp=timestamp)
triggered_line_numbers = html_tools.strip_ignore_text(content=content,
wordlist=watch['trigger_text'],
mode='line numbers'
)
except Exception as e:
content.append({'line': f"File doesnt exist or unable to read timestamp {timestamp}", 'classes': ''})
output = render_template("preview.html",
content=content,
current_version=timestamp,
history_n=watch.history_n,
extra_stylesheets=extra_stylesheets,
extra_title=f" - Diff - {watch.label} @ {timestamp}",
triggered_line_numbers=triggered_line_numbers,
current_diff_url=watch['url'],
screenshot=watch.get_screenshot(),
watch=watch,
uuid=uuid,
is_html_webdriver=is_html_webdriver,
last_error=watch['last_error'],
last_error_text=watch.get_error_text(),
last_error_screenshot=watch.get_error_snapshot(),
versions=versions
)
return output
@views_blueprint.route("/diff/<string:uuid>", methods=['POST'])
@login_optionally_required
def diff_history_page_build_report(uuid):
from changedetectionio import forms
# More for testing, possible to return the first/only
if uuid == 'first':
uuid = list(datastore.data['watching'].keys()).pop()
try:
watch = datastore.data['watching'][uuid]
except KeyError:
flash("No history found for the specified link, bad link?", "error")
return redirect(url_for('watchlist.index'))
# For submission of requesting an extract
extract_form = forms.extractDataForm(formdata=request.form,
data={'extract_regex': request.form.get('extract_regex', '')}
)
if not extract_form.validate():
flash("An error occurred, please see below.", "error")
return _render_diff_template(uuid, extract_form)
else:
extract_regex = request.form.get('extract_regex', '').strip()
output = watch.extract_regex_from_all_history(extract_regex)
if output:
watch_dir = os.path.join(datastore.datastore_path, uuid)
response = make_response(send_from_directory(directory=watch_dir, path=output, as_attachment=True))
response.headers['Content-type'] = 'text/csv'
response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
response.headers['Pragma'] = 'no-cache'
response.headers['Expires'] = "0"
return response
flash('No matches found while scanning all of the watch history for that RegEx.', 'error')
return redirect(url_for('ui.ui_views.diff_history_page', uuid=uuid) + '#extract')
def _render_diff_template(uuid, extract_form=None):
"""Helper function to render the diff template with all required data"""
from changedetectionio import forms
# More for testing, possible to return the first/only
if uuid == 'first':
uuid = list(datastore.data['watching'].keys()).pop()
extra_stylesheets = [url_for('static_content', group='styles', filename='diff.css')]
try:
watch = datastore.data['watching'][uuid]
except KeyError:
flash("No history found for the specified link, bad link?", "error")
return redirect(url_for('watchlist.index'))
# Use provided form or create a new one
if extract_form is None:
extract_form = forms.extractDataForm(formdata=request.form,
data={'extract_regex': request.form.get('extract_regex', '')}
)
history = watch.history
dates = list(history.keys())
# If a "from_version" was requested, then find it (or the closest one)
# Also set "from version" to be the closest version to the one that was last viewed.
best_last_viewed_timestamp = watch.get_from_version_based_on_last_viewed
from_version_timestamp = best_last_viewed_timestamp if best_last_viewed_timestamp else dates[-2]
from_version = request.args.get('from_version', from_version_timestamp )
# Use the current one if nothing was specified
to_version = request.args.get('to_version', str(dates[-1]))
try:
to_version_file_contents = watch.get_history_snapshot(timestamp=to_version)
except Exception as e:
logger.error(f"Unable to read watch history to-version for version {to_version}: {str(e)}")
to_version_file_contents = f"Unable to read to-version at {to_version}.\n"
try:
from_version_file_contents = watch.get_history_snapshot(timestamp=from_version)
except Exception as e:
logger.error(f"Unable to read watch history from-version for version {from_version}: {str(e)}")
from_version_file_contents = f"Unable to read to-version {from_version}.\n"
screenshot_url = watch.get_screenshot()
system_uses_webdriver = datastore.data['settings']['application']['fetch_backend'] == 'html_webdriver'
is_html_webdriver = False
if (watch.get('fetch_backend') == 'system' and system_uses_webdriver) or watch.get('fetch_backend') == 'html_webdriver' or watch.get('fetch_backend', '').startswith('extra_browser_'):
is_html_webdriver = True
password_enabled_and_share_is_off = False
if datastore.data['settings']['application'].get('password') or os.getenv("SALTED_PASS", False):
password_enabled_and_share_is_off = not datastore.data['settings']['application'].get('shared_diff_access')
datastore.set_last_viewed(uuid, time.time())
return render_template("diff.html",
current_diff_url=watch['url'],
from_version=str(from_version),
to_version=str(to_version),
extra_stylesheets=extra_stylesheets,
extra_title=f" - Diff - {watch.label}",
extract_form=extract_form,
is_html_webdriver=is_html_webdriver,
last_error=watch['last_error'],
last_error_screenshot=watch.get_error_snapshot(),
last_error_text=watch.get_error_text(),
left_sticky=True,
newest=to_version_file_contents,
newest_version_timestamp=dates[-1],
password_enabled_and_share_is_off=password_enabled_and_share_is_off,
from_version_file_contents=from_version_file_contents,
to_version_file_contents=to_version_file_contents,
screenshot=screenshot_url,
uuid=uuid,
versions=dates, # All except current/last
watch_a=watch
)
@views_blueprint.route("/diff/<string:uuid>", methods=['GET'])
@login_optionally_required
def diff_history_page(uuid):
return _render_diff_template(uuid)
@views_blueprint.route("/form/add/quickwatch", methods=['POST'])
@login_optionally_required
@@ -167,7 +167,7 @@ document.addEventListener('DOMContentLoaded', function() {
{% endif %}
<a class="external" target="_blank" rel="noopener" href="{{ watch.link.replace('source:','') }}">&nbsp;</a>
</span>
<div class="error-text" style="display:none;">{{ watch.compile_error_texts(has_proxies=datastore.proxy_list) }}</div>
<div class="error-text" style="display:none;">{{ watch.compile_error_texts(has_proxies=datastore.proxy_list)|safe }}</div>
{%- if watch['processor'] == 'text_json_diff' -%}
{%- if watch['has_ldjson_price_data'] and not watch['track_ldjson_price_data'] -%}
<div class="ldjson-price-track-offer">Switch to Restock & Price watch mode? <a href="{{url_for('price_data_follower.accept', uuid=watch.uuid)}}" class="pure-button button-xsmall">Yes</a> <a href="{{url_for('price_data_follower.reject', uuid=watch.uuid)}}" class="">No</a></div>
@@ -182,11 +182,9 @@ document.addEventListener('DOMContentLoaded', function() {
</div>
<div class="status-icons">
<a class="link-spread" href="{{url_for('ui.form_share_put_watch', uuid=watch.uuid)}}"><img src="{{url_for('static_content', group='images', filename='spread.svg')}}" class="status-icon icon icon-spread" title="Create a link to share watch config with others" ></a>
{%- if watch.get_fetch_backend == "html_webdriver"
or ( watch.get_fetch_backend == "system" and system_default_fetcher == 'html_webdriver' )
or "extra_browser_" in watch.get_fetch_backend
-%}
<img class="status-icon" src="{{url_for('static_content', group='images', filename='google-chrome-icon.png')}}" alt="Using a Chrome browser" title="Using a Chrome browser" >
{%- set effective_fetcher = watch.get_fetch_backend if watch.get_fetch_backend != "system" else system_default_fetcher -%}
{%- if effective_fetcher and ("html_webdriver" in effective_fetcher or "html_" in effective_fetcher or "extra_browser_" in effective_fetcher) -%}
{{ effective_fetcher|fetcher_status_icons }}
{%- endif -%}
{%- if watch.is_pdf -%}<img class="status-icon" src="{{url_for('static_content', group='images', filename='pdf-icon.svg')}}" alt="Converting PDF to text" >{%- endif -%}
{%- if watch.has_browser_steps -%}<img class="status-icon status-browsersteps" src="{{url_for('static_content', group='images', filename='steps.svg')}}" alt="Browser Steps is enabled" >{%- endif -%}
@@ -207,7 +205,7 @@ document.addEventListener('DOMContentLoaded', function() {
{%- if watch.get('restock') and watch['restock']['price'] != None -%}
{%- if watch['restock']['price'] != None -%}
<span class="restock-label price" title="Price">
{{ watch['restock']['price']|format_number_locale }} {{ watch['restock']['currency'] }}
{{ watch['restock']['price']|format_number_locale if watch['restock'].get('price') else '' }} {{ watch['restock'].get('currency','') }}
</span>
{%- endif -%}
{%- elif not watch.has_restock_info -%}
@@ -219,7 +217,7 @@ document.addEventListener('DOMContentLoaded', function() {
{#last_checked becomes fetch-start-time#}
<td class="last-checked" data-timestamp="{{ watch.last_checked }}" data-fetchduration={{ watch.fetch_time }} data-eta_complete="{{ watch.last_checked+watch.fetch_time }}" >
<div class="spinner-wrapper" style="display:none;" >
<span class="spinner"></span><span>&nbsp;Checking now</span>
<span class="spinner"></span><span class="status-text">&nbsp;Checking now</span>
</div>
<span class="innertext">{{watch|format_last_checked_time|safe}}</span>
</td>
@@ -235,8 +233,8 @@ document.addEventListener('DOMContentLoaded', function() {
<a href="" class="already-in-queue-button recheck pure-button pure-button-primary" style="display: none;" disabled="disabled">Queued</a>
<a href="{{ url_for('ui.form_watch_checknow', uuid=watch.uuid, tag=request.args.get('tag')) }}" data-op='recheck' class="ajax-op recheck pure-button pure-button-primary">Recheck</a>
<a href="{{ url_for('ui.ui_edit.edit_page', uuid=watch.uuid, tag=active_tag_uuid)}}#general" class="pure-button pure-button-primary">Edit</a>
<a href="{{ url_for('ui.ui_views.diff_history_page', uuid=watch.uuid)}}" {{target_attr}} class="pure-button pure-button-primary history-link" style="display: none;">History</a>
<a href="{{ url_for('ui.ui_views.preview_page', uuid=watch.uuid)}}" {{target_attr}} class="pure-button pure-button-primary preview-link" style="display: none;">Preview</a>
<a href="{{ url_for('ui.ui_diff.diff_history_page', uuid=watch.uuid)}}" {{target_attr}} class="pure-button pure-button-primary history-link" style="display: none;">History</a>
<a href="{{ url_for('ui.ui_preview.preview_page', uuid=watch.uuid)}}" {{target_attr}} class="pure-button pure-button-primary preview-link" style="display: none;">Preview</a>
</div>
</td>
</tr>
@@ -1,3 +1,7 @@
"""
Levenshtein distance and similarity plugin for text change detection.
Provides metrics for measuring text similarity between snapshots.
"""
import pluggy
from loguru import logger
@@ -1,3 +1,7 @@
"""
Word count plugin for content analysis.
Provides word count metrics for snapshot content.
"""
import pluggy
from loguru import logger
+47 -2
View File
@@ -7,6 +7,9 @@ import os
# Visual Selector scraper - 'Button' is there because some sites have <button>OUT OF STOCK</button>.
visualselector_xpath_selectors = 'div,span,form,table,tbody,tr,td,a,p,ul,li,h1,h2,h3,h4,header,footer,section,article,aside,details,main,nav,section,summary,button'
# Import hookimpl from centralized pluggy interface
from changedetectionio.pluggy_interface import hookimpl
SCREENSHOT_MAX_HEIGHT_DEFAULT = 20000
SCREENSHOT_DEFAULT_QUALITY = 40
@@ -35,17 +38,54 @@ def available_fetchers():
# See the if statement at the bottom of this file for how we switch between playwright and webdriver
import inspect
p = []
# Get built-in fetchers (but skip plugin fetchers that were added via setattr)
for name, obj in inspect.getmembers(sys.modules[__name__], inspect.isclass):
if inspect.isclass(obj):
# @todo html_ is maybe better as fetcher_ or something
# In this case, make sure to edit the default one in store.py and fetch_site_status.py
if name.startswith('html_'):
t = tuple([name, obj.fetcher_description])
p.append(t)
# Skip plugin fetchers that were already registered
if name not in _plugin_fetchers:
t = tuple([name, obj.fetcher_description])
p.append(t)
# Get plugin fetchers from cache (already loaded at module init)
for name, fetcher_class in _plugin_fetchers.items():
if hasattr(fetcher_class, 'fetcher_description'):
t = tuple([name, fetcher_class.fetcher_description])
p.append(t)
else:
logger.warning(f"Plugin fetcher '{name}' does not have fetcher_description attribute")
return p
def get_plugin_fetchers():
"""Load and return all plugin fetchers from the centralized plugin manager."""
from changedetectionio.pluggy_interface import plugin_manager
fetchers = {}
try:
# Call the register_content_fetcher hook from all registered plugins
results = plugin_manager.hook.register_content_fetcher()
for result in results:
if result:
name, fetcher_class = result
fetchers[name] = fetcher_class
# Register in current module so hasattr() checks work
setattr(sys.modules[__name__], name, fetcher_class)
logger.info(f"Registered plugin fetcher: {name} - {getattr(fetcher_class, 'fetcher_description', 'No description')}")
except Exception as e:
logger.error(f"Error loading plugin fetchers: {e}")
return fetchers
# Initialize plugins at module load time
_plugin_fetchers = get_plugin_fetchers()
# Decide which is the 'real' HTML webdriver, this is more a system wide config
# rather than site-specific.
use_playwright_as_chrome_fetcher = os.getenv('PLAYWRIGHT_DRIVER_URL', False)
@@ -62,3 +102,8 @@ else:
logger.debug("Falling back to selenium as fetcher")
from .webdriver_selenium import fetcher as html_webdriver
# Register built-in fetchers as plugins after all imports are complete
from changedetectionio.pluggy_interface import register_builtin_fetchers
register_builtin_fetchers()
@@ -64,6 +64,30 @@ class Fetcher():
# Time ONTOP of the system defined env minimum time
render_extract_delay = 0
# Fetcher capability flags - subclasses should override these
# These indicate what features the fetcher supports
supports_browser_steps = False # Can execute browser automation steps
supports_screenshots = False # Can capture page screenshots
supports_xpath_element_data = False # Can extract xpath element positions/data for visual selector
@classmethod
def get_status_icon_data(cls):
"""Return data for status icon to display in the watch overview.
This method can be overridden by subclasses to provide custom status icons.
Returns:
dict or None: Dictionary with icon data:
{
'filename': 'icon-name.svg', # Icon filename
'alt': 'Alt text', # Alt attribute
'title': 'Tooltip text', # Title attribute
'style': 'height: 1em;' # Optional inline CSS
}
Or None if no icon
"""
return None
def clear_content(self):
"""
Explicitly clear all content from memory to free up heap space.
@@ -92,6 +116,7 @@ class Fetcher():
request_method=None,
timeout=None,
url=None,
watch_uuid=None,
):
# Should set self.error, self.status_code and self.content
pass
@@ -89,6 +89,20 @@ class fetcher(Fetcher):
proxy = None
# Capability flags
supports_browser_steps = True
supports_screenshots = True
supports_xpath_element_data = True
@classmethod
def get_status_icon_data(cls):
"""Return Chrome browser icon data for Playwright fetcher."""
return {
'filename': 'google-chrome-icon.png',
'alt': 'Using a Chrome browser',
'title': 'Using a Chrome browser'
}
def __init__(self, proxy_override=None, custom_browser_connection_url=None):
super().__init__()
@@ -153,6 +167,7 @@ class fetcher(Fetcher):
request_method=None,
timeout=None,
url=None,
watch_uuid=None,
):
from playwright.async_api import async_playwright
@@ -330,4 +345,17 @@ class fetcher(Fetcher):
browser = None
# Plugin registration for built-in fetcher
class PlaywrightFetcherPlugin:
"""Plugin class that registers the Playwright fetcher as a built-in plugin."""
def register_content_fetcher(self):
"""Register the Playwright fetcher"""
return ('html_webdriver', fetcher)
# Create module-level instance for plugin registration
playwright_plugin = PlaywrightFetcherPlugin()
@@ -98,6 +98,20 @@ class fetcher(Fetcher):
proxy = None
# Capability flags
supports_browser_steps = True
supports_screenshots = True
supports_xpath_element_data = True
@classmethod
def get_status_icon_data(cls):
"""Return Chrome browser icon data for Puppeteer fetcher."""
return {
'filename': 'google-chrome-icon.png',
'alt': 'Using a Chrome browser',
'title': 'Using a Chrome browser'
}
def __init__(self, proxy_override=None, custom_browser_connection_url=None):
super().__init__()
@@ -155,6 +169,7 @@ class fetcher(Fetcher):
request_method,
timeout,
url,
watch_uuid
):
import re
self.delete_browser_steps_screenshots()
@@ -362,6 +377,7 @@ class fetcher(Fetcher):
request_method=None,
timeout=None,
url=None,
watch_uuid=None,
):
#@todo make update_worker async which could run any of these content_fetchers within memory and time constraints
@@ -380,7 +396,21 @@ class fetcher(Fetcher):
request_method=request_method,
timeout=timeout,
url=url,
watch_uuid=watch_uuid,
), timeout=max_time
)
except asyncio.TimeoutError:
raise (BrowserFetchTimedOut(msg=f"Browser connected but was unable to process the page in {max_time} seconds."))
# Plugin registration for built-in fetcher
class PuppeteerFetcherPlugin:
"""Plugin class that registers the Puppeteer fetcher as a built-in plugin."""
def register_content_fetcher(self):
"""Register the Puppeteer fetcher"""
return ('html_webdriver', fetcher)
# Create module-level instance for plugin registration
puppeteer_plugin = PuppeteerFetcherPlugin()
+18 -2
View File
@@ -26,7 +26,9 @@ class fetcher(Fetcher):
ignore_status_codes=False,
current_include_filters=None,
is_binary=False,
empty_pages_are_a_change=False):
empty_pages_are_a_change=False,
watch_uuid=None,
):
"""Synchronous version of run - the original requests implementation"""
import chardet
@@ -129,6 +131,7 @@ class fetcher(Fetcher):
request_method=None,
timeout=None,
url=None,
watch_uuid=None,
):
"""Async wrapper that runs the synchronous requests code in a thread pool"""
@@ -146,7 +149,8 @@ class fetcher(Fetcher):
ignore_status_codes=ignore_status_codes,
current_include_filters=current_include_filters,
is_binary=is_binary,
empty_pages_are_a_change=empty_pages_are_a_change
empty_pages_are_a_change=empty_pages_are_a_change,
watch_uuid=watch_uuid,
)
)
@@ -163,3 +167,15 @@ class fetcher(Fetcher):
except Exception as e:
logger.warning(f"Failed to unlink screenshot: {screenshot} - {e}")
# Plugin registration for built-in fetcher
class RequestsFetcherPlugin:
"""Plugin class that registers the requests fetcher as a built-in plugin."""
def register_content_fetcher(self):
"""Register the requests fetcher"""
return ('html_requests', fetcher)
# Create module-level instance for plugin registration
requests_plugin = RequestsFetcherPlugin()
@@ -14,6 +14,20 @@ class fetcher(Fetcher):
proxy = None
proxy_url = None
# Capability flags
supports_browser_steps = True
supports_screenshots = True
supports_xpath_element_data = True
@classmethod
def get_status_icon_data(cls):
"""Return Chrome browser icon data for WebDriver fetcher."""
return {
'filename': 'google-chrome-icon.png',
'alt': 'Using a Chrome browser',
'title': 'Using a Chrome browser'
}
def __init__(self, proxy_override=None, custom_browser_connection_url=None):
super().__init__()
from urllib.parse import urlparse
@@ -57,6 +71,7 @@ class fetcher(Fetcher):
request_method=None,
timeout=None,
url=None,
watch_uuid=None,
):
import asyncio
@@ -141,3 +156,16 @@ class fetcher(Fetcher):
# Run the selenium operations in a thread pool to avoid blocking the event loop
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, _run_sync)
# Plugin registration for built-in fetcher
class WebDriverSeleniumFetcherPlugin:
"""Plugin class that registers the WebDriver Selenium fetcher as a built-in plugin."""
def register_content_fetcher(self):
"""Register the WebDriver Selenium fetcher"""
return ('html_webdriver', fetcher)
# Create module-level instance for plugin registration
webdriver_selenium_plugin = WebDriverSeleniumFetcherPlugin()
-130
View File
@@ -1,130 +0,0 @@
import difflib
from typing import List, Iterator, Union
# https://github.com/dgtlmoon/changedetection.io/issues/821#issuecomment-1241837050
#HTML_ADDED_STYLE = "background-color: #d2f7c2; color: #255d00;"
#HTML_CHANGED_INTO_STYLE = "background-color: #dafbe1; color: #116329;"
#HTML_CHANGED_STYLE = "background-color: #ffd6cc; color: #7a2000;"
#HTML_REMOVED_STYLE = "background-color: #ffebe9; color: #82071e;"
# @todo - In the future we can make this configurable
HTML_ADDED_STYLE = "background-color: #eaf2c2; color: #406619"
HTML_REMOVED_STYLE = "background-color: #fadad7; color: #b30000"
HTML_CHANGED_STYLE = HTML_REMOVED_STYLE
HTML_CHANGED_INTO_STYLE = HTML_ADDED_STYLE
# These get set to html or telegram type or discord compatible or whatever in handler.py
# Something that cant get escaped to HTML by accident
REMOVED_PLACEMARKER_OPEN = '@removed_PLACEMARKER_OPEN'
REMOVED_PLACEMARKER_CLOSED = '@removed_PLACEMARKER_CLOSED'
ADDED_PLACEMARKER_OPEN = '@added_PLACEMARKER_OPEN'
ADDED_PLACEMARKER_CLOSED = '@added_PLACEMARKER_CLOSED'
CHANGED_PLACEMARKER_OPEN = '@changed_PLACEMARKER_OPEN'
CHANGED_PLACEMARKER_CLOSED = '@changed_PLACEMARKER_CLOSED'
CHANGED_INTO_PLACEMARKER_OPEN = '@changed_into_PLACEMARKER_OPEN'
CHANGED_INTO_PLACEMARKER_CLOSED = '@changed_into_PLACEMARKER_CLOSED'
def same_slicer(lst: List[str], start: int, end: int) -> List[str]:
"""Return a slice of the list, or a single element if start == end."""
return lst[start:end] if start != end else [lst[start]]
def customSequenceMatcher(
before: List[str],
after: List[str],
include_equal: bool = False,
include_removed: bool = True,
include_added: bool = True,
include_replaced: bool = True,
include_change_type_prefix: bool = True
) -> Iterator[List[str]]:
"""
Compare two sequences and yield differences based on specified parameters.
Args:
before (List[str]): Original sequence
after (List[str]): Modified sequence
include_equal (bool): Include unchanged parts
include_removed (bool): Include removed parts
include_added (bool): Include added parts
include_replaced (bool): Include replaced parts
include_change_type_prefix (bool): Add prefixes to indicate change types
Yields:
List[str]: Differences between sequences
"""
cruncher = difflib.SequenceMatcher(isjunk=lambda x: x in " \t", a=before, b=after)
for tag, alo, ahi, blo, bhi in cruncher.get_opcodes():
if include_equal and tag == 'equal':
yield before[alo:ahi]
elif include_removed and tag == 'delete':
if include_change_type_prefix:
yield [f'{REMOVED_PLACEMARKER_OPEN}{line}{REMOVED_PLACEMARKER_CLOSED}' for line in same_slicer(before, alo, ahi)]
else:
yield same_slicer(before, alo, ahi)
elif include_replaced and tag == 'replace':
if include_change_type_prefix:
yield [f'{CHANGED_PLACEMARKER_OPEN}{line}{CHANGED_PLACEMARKER_CLOSED}' for line in same_slicer(before, alo, ahi)] + \
[f'{CHANGED_INTO_PLACEMARKER_OPEN}{line}{CHANGED_INTO_PLACEMARKER_CLOSED}' for line in same_slicer(after, blo, bhi)]
else:
yield same_slicer(before, alo, ahi) + same_slicer(after, blo, bhi)
elif include_added and tag == 'insert':
if include_change_type_prefix:
yield [f'{ADDED_PLACEMARKER_OPEN}{line}{ADDED_PLACEMARKER_CLOSED}' for line in same_slicer(after, blo, bhi)]
else:
yield same_slicer(after, blo, bhi)
def render_diff(
previous_version_file_contents: str,
newest_version_file_contents: str,
include_equal: bool = False,
include_removed: bool = True,
include_added: bool = True,
include_replaced: bool = True,
line_feed_sep: str = "\n",
include_change_type_prefix: bool = True,
patch_format: bool = False
) -> str:
"""
Render the difference between two file contents.
Args:
previous_version_file_contents (str): Original file contents
newest_version_file_contents (str): Modified file contents
include_equal (bool): Include unchanged parts
include_removed (bool): Include removed parts
include_added (bool): Include added parts
include_replaced (bool): Include replaced parts
line_feed_sep (str): Separator for lines in output
include_change_type_prefix (bool): Add prefixes to indicate change types
patch_format (bool): Use patch format for output
Returns:
str: Rendered difference
"""
newest_lines = [line.rstrip() for line in newest_version_file_contents.splitlines()]
previous_lines = [line.rstrip() for line in previous_version_file_contents.splitlines()] if previous_version_file_contents else []
if patch_format:
patch = difflib.unified_diff(previous_lines, newest_lines)
return line_feed_sep.join(patch)
rendered_diff = customSequenceMatcher(
before=previous_lines,
after=newest_lines,
include_equal=include_equal,
include_removed=include_removed,
include_added=include_added,
include_replaced=include_replaced,
include_change_type_prefix=include_change_type_prefix
)
def flatten(lst: List[Union[str, List[str]]]) -> str:
return line_feed_sep.join(flatten(x) if isinstance(x, list) else x for x in lst)
return flatten(rendered_diff)
+479
View File
@@ -0,0 +1,479 @@
"""
Diff rendering module for change detection.
This module provides functions for rendering differences between text content,
with support for various output formats and tokenization strategies.
"""
import difflib
from typing import List, Iterator, Union
from loguru import logger
import diff_match_patch as dmp_module
import re
import time
from .tokenizers import TOKENIZERS, tokenize_words_and_html
# Remember! gmail, outlook etc dont support <style> must be inline.
# Gmail: strips <ins> and <del> tags entirely.
# This is for the WHOLE line background style
REMOVED_STYLE = "background-color: #fadad7; color: #b30000;"
ADDED_STYLE = "background-color: #eaf2c2; color: #406619;"
HTML_REMOVED_STYLE = REMOVED_STYLE # Export alias for handler.py
HTML_ADDED_STYLE = ADDED_STYLE # Export alias for handler.py
# Darker backgrounds for nested highlighting (changed parts within lines)
REMOVED_INNER_STYLE = "background-color: #ff867a; color: #111;"
ADDED_INNER_STYLE = "background-color: #b2e841; color: #444;"
HTML_CHANGED_STYLE = REMOVED_STYLE
HTML_CHANGED_INTO_STYLE = ADDED_STYLE
# Placemarker constants - these get replaced by apply_service_tweaks() in handler.py
# Something that cant get escaped to HTML by accident
REMOVED_PLACEMARKER_OPEN = '@removed_PLACEMARKER_OPEN'
REMOVED_PLACEMARKER_CLOSED = '@removed_PLACEMARKER_CLOSED'
ADDED_PLACEMARKER_OPEN = '@added_PLACEMARKER_OPEN'
ADDED_PLACEMARKER_CLOSED = '@added_PLACEMARKER_CLOSED'
CHANGED_PLACEMARKER_OPEN = '@changed_PLACEMARKER_OPEN'
CHANGED_PLACEMARKER_CLOSED = '@changed_PLACEMARKER_CLOSED'
CHANGED_INTO_PLACEMARKER_OPEN = '@changed_into_PLACEMARKER_OPEN'
CHANGED_INTO_PLACEMARKER_CLOSED = '@changed_into_PLACEMARKER_CLOSED'
# Compiled regex patterns for performance
WHITESPACE_NORMALIZE_RE = re.compile(r'\s+')
def render_inline_word_diff(before_line: str, after_line: str, ignore_junk: bool = False, markdown_style: str = None, tokenizer: str = 'words_and_html') -> tuple[str, bool]:
"""
Render word-level differences between two lines inline using diff-match-patch library.
Args:
before_line: Original line text
after_line: Modified line text
ignore_junk: Ignore whitespace-only changes
markdown_style: Unused (kept for backwards compatibility)
tokenizer: Name of tokenizer to use from TOKENIZERS registry (default: 'words_and_html')
Returns:
tuple[str, bool]: (diff output with inline word-level highlighting, has_changes flag)
"""
# Normalize whitespace if ignore_junk is enabled
if ignore_junk:
# Normalize whitespace: replace multiple spaces/tabs with single space
before_normalized = WHITESPACE_NORMALIZE_RE.sub(' ', before_line)
after_normalized = WHITESPACE_NORMALIZE_RE.sub(' ', after_line)
else:
before_normalized = before_line
after_normalized = after_line
# Use diff-match-patch with word-level tokenization
# Strategy: Use linesToChars to treat words as atomic units
dmp = dmp_module.diff_match_patch()
# Get the tokenizer function from the registry
tokenizer_func = TOKENIZERS.get(tokenizer, tokenize_words_and_html)
# Tokenize both lines using the selected tokenizer
before_tokens = tokenizer_func(before_normalized)
after_tokens = tokenizer_func(after_normalized or ' ')
# Create mappings for linesToChars (using it for word-mode)
# Join tokens with newline so each "line" is a token
before_text = '\n'.join(before_tokens)
after_text = '\n'.join(after_tokens)
# Use linesToChars for word-mode diffing
lines_result = dmp.diff_linesToChars(before_text, after_text)
line_before, line_after, line_array = lines_result
# Perform diff on the encoded strings
diffs = dmp.diff_main(line_before, line_after, False)
# Convert back to original text
dmp.diff_charsToLines(diffs, line_array)
# Remove the newlines we added for tokenization
diffs = [(op, text.replace('\n', '')) for op, text in diffs]
# DON'T apply semantic cleanup here - it would break token boundaries
# (e.g., "63" -> "66" would become "6" + "3" vs "6" + "6")
# We want to preserve the tokenizer's word boundaries
# Check if there are any changes
has_changes = any(op != 0 for op, _ in diffs)
if ignore_junk and not has_changes:
return after_line, False
# Check if the whole line is replaced (no unchanged content)
whole_line_replaced = not any(op == 0 and text.strip() for op, text in diffs)
# Build the output using placemarkers
# When whole line is replaced, wrap entire removed content once and entire added content once
if whole_line_replaced:
removed_tokens = []
added_tokens = []
for op, text in diffs:
if op == 0: # Equal (e.g., whitespace tokens in common positions)
# Include in both removed and added to preserve spacing
removed_tokens.append(text)
added_tokens.append(text)
elif op == -1: # Deletion
removed_tokens.append(text)
elif op == 1: # Insertion
added_tokens.append(text)
# Join all tokens and wrap the entire string once for removed, once for added
result_parts = []
if removed_tokens:
removed_full = ''.join(removed_tokens).rstrip()
trailing_removed = ''.join(removed_tokens)[len(removed_full):] if len(''.join(removed_tokens)) > len(removed_full) else ''
result_parts.append(f'{CHANGED_PLACEMARKER_OPEN}{removed_full}{CHANGED_PLACEMARKER_CLOSED}{trailing_removed}')
if added_tokens:
if result_parts: # Add newline between removed and added
result_parts.append('\n')
added_full = ''.join(added_tokens).rstrip()
trailing_added = ''.join(added_tokens)[len(added_full):] if len(''.join(added_tokens)) > len(added_full) else ''
result_parts.append(f'{CHANGED_INTO_PLACEMARKER_OPEN}{added_full}{CHANGED_INTO_PLACEMARKER_CLOSED}{trailing_added}')
return ''.join(result_parts), has_changes
else:
# Inline changes within the line
result_parts = []
for op, text in diffs:
if op == 0: # Equal
result_parts.append(text)
elif op == 1: # Insertion
# Don't wrap empty content (e.g., whitespace-only tokens after rstrip)
content = text.rstrip()
trailing = text[len(content):] if len(text) > len(content) else ''
if content:
result_parts.append(f'{ADDED_PLACEMARKER_OPEN}{content}{ADDED_PLACEMARKER_CLOSED}{trailing}')
else:
result_parts.append(trailing)
elif op == -1: # Deletion
# Don't wrap empty content (e.g., whitespace-only tokens after rstrip)
content = text.rstrip()
trailing = text[len(content):] if len(text) > len(content) else ''
if content:
result_parts.append(f'{REMOVED_PLACEMARKER_OPEN}{content}{REMOVED_PLACEMARKER_CLOSED}{trailing}')
else:
result_parts.append(trailing)
return ''.join(result_parts), has_changes
def render_nested_line_diff(before_line: str, after_line: str, ignore_junk: bool = False, tokenizer: str = 'words_and_html') -> tuple[str, str, bool]:
"""
Render line-level differences with nested highlighting for changed parts.
Returns two separate lines:
- Before line: light red background with dark red on removed parts
- After line: light green background with dark green on added parts
Args:
before_line: Original line text
after_line: Modified line text
ignore_junk: Ignore whitespace-only changes
tokenizer: Name of tokenizer to use from TOKENIZERS registry
Returns:
tuple[str, str, bool]: (before_with_highlights, after_with_highlights, has_changes)
"""
# Normalize whitespace if ignore_junk is enabled
if ignore_junk:
before_normalized = WHITESPACE_NORMALIZE_RE.sub(' ', before_line)
after_normalized = WHITESPACE_NORMALIZE_RE.sub(' ', after_line)
else:
before_normalized = before_line
after_normalized = after_line
# Use diff-match-patch with word-level tokenization
dmp = dmp_module.diff_match_patch()
# Get the tokenizer function from the registry
tokenizer_func = TOKENIZERS.get(tokenizer, tokenize_words_and_html)
# Tokenize both lines
before_tokens = tokenizer_func(before_normalized)
after_tokens = tokenizer_func(after_normalized or ' ')
# Create mappings for linesToChars
before_text = '\n'.join(before_tokens)
after_text = '\n'.join(after_tokens)
# Use linesToChars for word-mode diffing
lines_result = dmp.diff_linesToChars(before_text, after_text)
line_before, line_after, line_array = lines_result
# Perform diff on the encoded strings
diffs = dmp.diff_main(line_before, line_after, False)
# Convert back to original text
dmp.diff_charsToLines(diffs, line_array)
# Remove the newlines we added for tokenization
diffs = [(op, text.replace('\n', '')) for op, text in diffs]
# DON'T apply semantic cleanup here - it would break token boundaries
# (e.g., "63" -> "66" would become "6" + "3" vs "6" + "6")
# We want to preserve the tokenizer's word boundaries
# Check if there are any changes
has_changes = any(op != 0 for op, _ in diffs)
if ignore_junk and not has_changes:
return before_line, after_line, False
# Build the before line (with nested highlighting for removed parts)
before_parts = []
for op, text in diffs:
if op == 0: # Equal
before_parts.append(text)
elif op == -1: # Deletion (in before)
before_parts.append(f'<span style="{REMOVED_INNER_STYLE}">{text}</span>')
# Skip insertions (op == 1) for the before line
before_content = ''.join(before_parts)
# Build the after line (with nested highlighting for added parts)
after_parts = []
for op, text in diffs:
if op == 0: # Equal
after_parts.append(text)
elif op == 1: # Insertion (in after)
after_parts.append(f'<span style="{ADDED_INNER_STYLE}">{text}</span>')
# Skip deletions (op == -1) for the after line
after_content = ''.join(after_parts)
# Wrap content with placemarkers (inner HTML highlighting is preserved)
before_html = f'{CHANGED_PLACEMARKER_OPEN}{before_content}{CHANGED_PLACEMARKER_CLOSED}'
after_html = f'{CHANGED_INTO_PLACEMARKER_OPEN}{after_content}{CHANGED_INTO_PLACEMARKER_CLOSED}'
return before_html, after_html, has_changes
def same_slicer(lst: List[str], start: int, end: int) -> List[str]:
"""Return a slice of the list, or a single element if start == end."""
return lst[start:end] if start != end else [lst[start]]
def customSequenceMatcher(
before: List[str],
after: List[str],
include_equal: bool = False,
include_removed: bool = True,
include_added: bool = True,
include_replaced: bool = True,
include_change_type_prefix: bool = True,
word_diff: bool = False,
context_lines: int = 0,
case_insensitive: bool = False,
ignore_junk: bool = False,
tokenizer: str = 'words_and_html'
) -> Iterator[List[str]]:
"""
Compare two sequences and yield differences based on specified parameters.
Args:
before (List[str]): Original sequence
after (List[str]): Modified sequence
include_equal (bool): Include unchanged parts
include_removed (bool): Include removed parts
include_added (bool): Include added parts
include_replaced (bool): Include replaced parts
include_change_type_prefix (bool): Add prefixes to indicate change types
word_diff (bool): Use word-level diffing for replaced lines (controls inline rendering)
context_lines (int): Number of unchanged lines to show around changes (like grep -C)
case_insensitive (bool): Perform case-insensitive comparison
ignore_junk (bool): Ignore whitespace-only changes
tokenizer (str): Name of tokenizer to use from TOKENIZERS registry (default: 'words_and_html')
Yields:
List[str]: Differences between sequences
"""
# Prepare sequences for comparison (lowercase if case-insensitive, normalize whitespace if ignore_junk)
def prepare_line(line):
if case_insensitive:
line = line.lower()
if ignore_junk:
# Normalize whitespace: replace multiple spaces/tabs with single space
line = WHITESPACE_NORMALIZE_RE.sub(' ', line)
return line
compare_before = [prepare_line(line) for line in before]
compare_after = [prepare_line(line) for line in after]
cruncher = difflib.SequenceMatcher(isjunk=lambda x: x in " \t", a=compare_before, b=compare_after)
# When context_lines is set and include_equal is False, we need to track which equal lines to include
if context_lines > 0 and not include_equal:
opcodes = list(cruncher.get_opcodes())
# Mark equal ranges that should be included based on context
included_equal_ranges = set()
for i, (tag, alo, ahi, blo, bhi) in enumerate(opcodes):
if tag != 'equal':
# Include context lines before this change
for j in range(max(0, i - 1), i):
if opcodes[j][0] == 'equal':
prev_alo, prev_ahi = opcodes[j][1], opcodes[j][2]
# Include last N lines of the previous equal block
context_start = max(prev_alo, prev_ahi - context_lines)
for line_num in range(context_start, prev_ahi):
included_equal_ranges.add(line_num)
# Include context lines after this change
for j in range(i + 1, min(len(opcodes), i + 2)):
if opcodes[j][0] == 'equal':
next_alo, next_ahi = opcodes[j][1], opcodes[j][2]
# Include first N lines of the next equal block
context_end = min(next_ahi, next_alo + context_lines)
for line_num in range(next_alo, context_end):
included_equal_ranges.add(line_num)
# Remember! gmail, outlook etc dont support <style> must be inline.
# Gmail: strips <ins> and <del> tags entirely.
for tag, alo, ahi, blo, bhi in cruncher.get_opcodes():
if tag == 'equal':
if include_equal:
yield before[alo:ahi]
elif context_lines > 0:
# Only include equal lines that are in the context range
context_lines_to_include = [before[i] for i in range(alo, ahi) if i in included_equal_ranges]
if context_lines_to_include:
yield context_lines_to_include
elif include_removed and tag == 'delete':
if include_change_type_prefix:
yield [f'{REMOVED_PLACEMARKER_OPEN}{line}{REMOVED_PLACEMARKER_CLOSED}' for line in same_slicer(before, alo, ahi)]
else:
yield same_slicer(before, alo, ahi)
elif include_replaced and tag == 'replace':
before_lines = same_slicer(before, alo, ahi)
after_lines = same_slicer(after, blo, bhi)
# Use inline word-level diff for single line replacements when word_diff is enabled
if word_diff and len(before_lines) == 1 and len(after_lines) == 1:
inline_diff, has_changes = render_inline_word_diff(before_lines[0], after_lines[0], ignore_junk=ignore_junk, tokenizer=tokenizer)
# Check if there are any actual changes (not just whitespace when ignore_junk is enabled)
if ignore_junk and not has_changes:
# No real changes, skip this line
continue
yield [inline_diff]
else:
# Fall back to line-level diff for multi-line changes
if include_change_type_prefix:
yield [f'{CHANGED_PLACEMARKER_OPEN}{line}{CHANGED_PLACEMARKER_CLOSED}' for line in before_lines] + \
[f'{CHANGED_INTO_PLACEMARKER_OPEN}{line}{CHANGED_INTO_PLACEMARKER_CLOSED}' for line in after_lines]
else:
yield before_lines + after_lines
elif include_added and tag == 'insert':
if include_change_type_prefix:
yield [f'{ADDED_PLACEMARKER_OPEN}{line}{ADDED_PLACEMARKER_CLOSED}' for line in same_slicer(after, blo, bhi)]
else:
yield same_slicer(after, blo, bhi)
def render_diff(
previous_version_file_contents: str,
newest_version_file_contents: str,
include_equal: bool = False,
include_removed: bool = True,
include_added: bool = True,
include_replaced: bool = True,
include_change_type_prefix: bool = True,
patch_format: bool = False,
word_diff: bool = True,
context_lines: int = 0,
case_insensitive: bool = False,
ignore_junk: bool = False,
tokenizer: str = 'words_and_html'
) -> str:
"""
Render the difference between two file contents.
Args:
previous_version_file_contents (str): Original file contents
newest_version_file_contents (str): Modified file contents
include_equal (bool): Include unchanged parts
include_removed (bool): Include removed parts
include_added (bool): Include added parts
include_replaced (bool): Include replaced parts
include_change_type_prefix (bool): Add prefixes to indicate change types
patch_format (bool): Use patch format for output
word_diff (bool): Use word-level diffing for replaced lines (controls inline rendering)
context_lines (int): Number of unchanged lines to show around changes (like grep -C)
case_insensitive (bool): Perform case-insensitive comparison, By default the test_json_diff/process.py is case sensitive, so this follows same logic
ignore_junk (bool): Ignore whitespace-only changes
tokenizer (str): Name of tokenizer to use from TOKENIZERS registry (default: 'words_and_html')
Returns:
str: Rendered difference
"""
newest_lines = [line.rstrip() for line in newest_version_file_contents.splitlines()]
previous_lines = [line.rstrip() for line in previous_version_file_contents.splitlines()] if previous_version_file_contents else []
now = time.time()
logger.debug(
f"diff options: "
f"include_equal={include_equal}, "
f"include_removed={include_removed}, "
f"include_added={include_added}, "
f"include_replaced={include_replaced}, "
f"include_change_type_prefix={include_change_type_prefix}, "
f"patch_format={patch_format}, "
f"word_diff={word_diff}, "
f"context_lines={context_lines}, "
f"case_insensitive={case_insensitive}, "
f"ignore_junk={ignore_junk}, "
f"tokenizer={tokenizer}"
)
if patch_format:
patch = difflib.unified_diff(previous_lines, newest_lines)
return "\n".join(patch)
rendered_diff = customSequenceMatcher(
before=previous_lines,
after=newest_lines,
include_equal=include_equal,
include_removed=include_removed,
include_added=include_added,
include_replaced=include_replaced,
include_change_type_prefix=include_change_type_prefix,
word_diff=word_diff,
context_lines=context_lines,
case_insensitive=case_insensitive,
ignore_junk=ignore_junk,
tokenizer=tokenizer
)
def flatten(lst: List[Union[str, List[str]]]) -> str:
result = []
for x in lst:
if isinstance(x, list):
result.extend(x)
else:
result.append(x)
return "\n".join(result)
logger.debug(f"Diff generated in {time.time() - now:.2f}s")
return flatten(rendered_diff)
# Export main public API
__all__ = [
'render_diff',
'customSequenceMatcher',
'render_inline_word_diff',
'render_nested_line_diff',
'TOKENIZERS',
'REMOVED_STYLE',
'ADDED_STYLE',
'REMOVED_INNER_STYLE',
'ADDED_INNER_STYLE',
]
@@ -0,0 +1,23 @@
"""
Tokenizers for diff operations.
This module provides various tokenization strategies for use with the diff system.
New tokenizers can be easily added by:
1. Creating a new module in this directory
2. Importing and registering it in the TOKENIZERS dictionary below
"""
from .natural_text import tokenize_words
from .words_and_html import tokenize_words_and_html
# Tokenizer registry - maps tokenizer names to functions
TOKENIZERS = {
'words': tokenize_words,
'words_and_html': tokenize_words_and_html,
}
__all__ = [
'tokenize_words',
'tokenize_words_and_html',
'TOKENIZERS',
]
@@ -0,0 +1,44 @@
"""
Simple word tokenizer using whitespace boundaries.
This is a simpler tokenizer that treats all whitespace as token boundaries
without special handling for HTML tags or other markup.
"""
from typing import List
def tokenize_words(text: str) -> List[str]:
"""
Split text into words using simple whitespace boundaries.
This is a simpler tokenizer that treats all whitespace as token boundaries
without special handling for HTML tags.
Args:
text: Input text to tokenize
Returns:
List of tokens (words and whitespace)
Examples:
>>> tokenize_words("Hello world")
['Hello', ' ', 'world']
>>> tokenize_words("one two")
['one', ' ', ' ', 'two']
"""
tokens = []
current = ''
for char in text:
if char.isspace():
if current:
tokens.append(current)
current = ''
tokens.append(char)
else:
current += char
if current:
tokens.append(current)
return tokens
@@ -0,0 +1,61 @@
"""
Tokenizer that preserves HTML tags as atomic units while splitting on whitespace.
This tokenizer is specifically designed for HTML content where:
- HTML tags should remain intact (e.g., '<p>', '<a href="...">')
- Whitespace tokens are preserved for accurate diff reconstruction
- Words are split on whitespace boundaries
"""
from typing import List
def tokenize_words_and_html(text: str) -> List[str]:
"""
Split text into words and boundaries (spaces, HTML tags).
This tokenizer preserves HTML tags as atomic units while splitting on whitespace.
Useful for content that contains HTML markup.
Args:
text: Input text to tokenize
Returns:
List of tokens (words, spaces, HTML tags)
Examples:
>>> tokenize_words_and_html("<p>Hello world</p>")
['<p>', 'Hello', ' ', 'world', '</p>']
>>> tokenize_words_and_html("<a href='test.com'>link</a>")
['<a href=\\'test.com\\'>', 'link', '</a>']
"""
tokens = []
current = ''
in_tag = False
for char in text:
if char == '<':
# Start of HTML tag
if current:
tokens.append(current)
current = ''
current = '<'
in_tag = True
elif char == '>' and in_tag:
# End of HTML tag
current += '>'
tokens.append(current)
current = ''
in_tag = False
elif char.isspace() and not in_tag:
# Space outside of tag
if current:
tokens.append(current)
current = ''
tokens.append(char)
else:
current += char
if current:
tokens.append(current)
return tokens
+99 -1
View File
@@ -38,7 +38,7 @@ from loguru import logger
from changedetectionio import __version__
from changedetectionio import queuedWatchMetaData
from changedetectionio.api import Watch, WatchHistory, WatchSingleHistory, CreateWatch, Import, SystemInfo, Tag, Tags, Notifications, WatchFavicon
from changedetectionio.api import Watch, WatchHistory, WatchSingleHistory, WatchHistoryDiff, CreateWatch, Import, SystemInfo, Tag, Tags, Notifications, WatchFavicon
from changedetectionio.api.Search import Search
from .time_handler import is_within_schedule
@@ -81,6 +81,27 @@ if os.getenv('FLASK_SERVER_NAME'):
# Disables caching of the templates
app.config['TEMPLATES_AUTO_RELOAD'] = True
app.jinja_env.add_extension('jinja2.ext.loopcontrols')
# Configure Jinja2 to search for templates in plugin directories
def _configure_plugin_templates():
"""Configure Jinja2 loader to include plugin template directories."""
from jinja2 import ChoiceLoader, FileSystemLoader
from changedetectionio.pluggy_interface import get_plugin_template_paths
# Get plugin template paths
plugin_template_paths = get_plugin_template_paths()
if plugin_template_paths:
# Create a ChoiceLoader that searches app templates first, then plugin templates
loaders = [app.jinja_loader] # Keep the default app loader first
for path in plugin_template_paths:
loaders.append(FileSystemLoader(path))
app.jinja_loader = ChoiceLoader(loaders)
logger.info(f"Configured Jinja2 to search {len(plugin_template_paths)} plugin template directories")
# Configure plugin templates (called after plugins are loaded)
_configure_plugin_templates()
csrf = CSRFProtect()
csrf.init_app(app)
notification_debug_log=[]
@@ -210,6 +231,55 @@ def _jinja2_filter_seconds_precise(timestamp):
return format(int(time.time()-timestamp), ',d')
@app.template_filter('fetcher_status_icons')
def _jinja2_filter_fetcher_status_icons(fetcher_name):
"""Get status icon HTML for a given fetcher.
This filter checks both built-in fetchers and plugin fetchers for status icons.
Args:
fetcher_name: The fetcher name (e.g., 'html_webdriver', 'html_js_zyte')
Returns:
str: HTML string containing status icon elements
"""
from changedetectionio import content_fetchers
from changedetectionio.pluggy_interface import collect_fetcher_status_icons
from markupsafe import Markup
from flask import url_for
icon_data = None
# First check if it's a plugin fetcher (plugins have priority)
plugin_icon_data = collect_fetcher_status_icons(fetcher_name)
if plugin_icon_data:
icon_data = plugin_icon_data
# Check if it's a built-in fetcher
elif hasattr(content_fetchers, fetcher_name):
fetcher_class = getattr(content_fetchers, fetcher_name)
if hasattr(fetcher_class, 'get_status_icon_data'):
icon_data = fetcher_class.get_status_icon_data()
# Build HTML from icon data
if icon_data and isinstance(icon_data, dict):
# Use 'group' from icon_data if specified, otherwise default to 'images'
group = icon_data.get('group', 'images')
# Try to use url_for, but fall back to manual URL building if endpoint not registered yet
try:
icon_url = url_for('static_content', group=group, filename=icon_data['filename'])
except:
# Fallback: build URL manually respecting APPLICATION_ROOT
from flask import request
app_root = request.script_root if hasattr(request, 'script_root') else ''
icon_url = f"{app_root}/static/{group}/{icon_data['filename']}"
style_attr = f' style="{icon_data["style"]}"' if icon_data.get('style') else ''
html = f'<img class="status-icon" src="{icon_url}" alt="{icon_data["alt"]}" title="{icon_data["title"]}"{style_attr}>'
return Markup(html)
return ''
# Import login_optionally_required from auth_decorator
from changedetectionio.auth_decorator import login_optionally_required
@@ -307,6 +377,9 @@ def changedetection_app(config=None, datastore_o=None):
return login_manager.unauthorized()
watch_api.add_resource(WatchHistoryDiff,
'/api/v1/watch/<string:uuid>/difference/<string:from_timestamp>/<string:to_timestamp>',
resource_class_kwargs={'datastore': datastore})
watch_api.add_resource(WatchSingleHistory,
'/api/v1/watch/<string:uuid>/history/<string:timestamp>',
resource_class_kwargs={'datastore': datastore, 'update_q': update_q})
@@ -488,6 +561,31 @@ def changedetection_app(config=None, datastore_o=None):
except FileNotFoundError:
abort(404)
# Handle plugin group specially
if group == 'plugin':
# Serve files from plugin static directories
from changedetectionio.pluggy_interface import plugin_manager
import os as os_check
for plugin_name, plugin_obj in plugin_manager.list_name_plugin():
if hasattr(plugin_obj, 'plugin_static_path'):
try:
static_path = plugin_obj.plugin_static_path()
if static_path and os_check.path.isdir(static_path):
# Check if file exists in plugin's static directory
plugin_file_path = os_check.path.join(static_path, filename)
if os_check.path.isfile(plugin_file_path):
# Found the file in a plugin
response = make_response(send_from_directory(static_path, filename))
response.headers['Cache-Control'] = 'max-age=3600, public' # Cache for 1 hour
return response
except Exception as e:
logger.debug(f"Error checking plugin {plugin_name} for static file: {e}")
pass
# File not found in any plugin
abort(404)
# These files should be in our subdirectory
try:
return send_from_directory(f"static/{group}", path=filename)
+3
View File
@@ -464,6 +464,9 @@ def strip_ignore_text(content, wordlist, mode="content"):
ignore_regex_multiline = []
ignored_lines = []
if not content:
return ''
for k in wordlist:
# Skip empty strings to avoid matching everything
if not k or not k.strip():
+4 -1
View File
@@ -826,6 +826,7 @@ class model(watch_base):
# has app+request context, we can use url_for()
if has_app_context:
if last_error:
last_error = safe_jinja.render_fully_escaped(last_error)
if '403' in last_error:
if has_proxies:
output.append(str(Markup(f"{last_error} - <a href=\"{url_for('settings.settings_page', uuid=self.get('uuid'))}\">Try other proxies/location</a>&nbsp;'")))
@@ -835,7 +836,9 @@ class model(watch_base):
output.append(str(Markup(last_error)))
if self.get('last_notification_error'):
output.append(str(Markup(f"<div class=\"notification-error\"><a href=\"{url_for('settings.notification_logs')}\">{ self.get('last_notification_error') }</a></div>")))
txt = safe_jinja.render_fully_escaped(self.get('last_notification_error'))
result = f'<div class="notification-error"><a href="{url_for("settings.notification_logs")}">{txt}</a></div>'
output.append(result)
else:
# Lo_Fi version - no app context, cant rely on Jinja2 Markup
+30 -2
View File
@@ -1,5 +1,6 @@
import time
import re
import apprise
from apprise import NotifyFormat
from loguru import logger
@@ -11,11 +12,10 @@ from ..diff import HTML_REMOVED_STYLE, REMOVED_PLACEMARKER_OPEN, REMOVED_PLACEMA
CHANGED_PLACEMARKER_CLOSED, HTML_CHANGED_STYLE, HTML_CHANGED_INTO_STYLE
import re
from ..notification_service import NotificationContextData
from ..notification_service import NotificationContextData, add_rendered_diff_to_notification_vars
newline_re = re.compile(r'\r\n|\r|\n')
def markup_text_links_to_html(body):
"""
Convert plaintext to HTML with clickable links.
@@ -79,6 +79,24 @@ def notification_format_align_with_apprise(n_format : str):
return n_format
def apply_html_color_to_body(n_body: str):
# https://github.com/dgtlmoon/changedetection.io/issues/821#issuecomment-1241837050
n_body = n_body.replace(REMOVED_PLACEMARKER_OPEN,
f'<span style="{HTML_REMOVED_STYLE}" role="deletion" aria-label="Removed text" title="Removed text">')
n_body = n_body.replace(REMOVED_PLACEMARKER_CLOSED, f'</span>')
n_body = n_body.replace(ADDED_PLACEMARKER_OPEN,
f'<span style="{HTML_ADDED_STYLE}" role="insertion" aria-label="Added text" title="Added text">')
n_body = n_body.replace(ADDED_PLACEMARKER_CLOSED, f'</span>')
# Handle changed/replaced lines (old → new)
n_body = n_body.replace(CHANGED_PLACEMARKER_OPEN,
f'<span style="{HTML_CHANGED_STYLE}" role="note" aria-label="Changed text" title="Changed text">')
n_body = n_body.replace(CHANGED_PLACEMARKER_CLOSED, f'</span>')
n_body = n_body.replace(CHANGED_INTO_PLACEMARKER_OPEN,
f'<span style="{HTML_CHANGED_INTO_STYLE}" role="note" aria-label="Changed into" title="Changed into">')
n_body = n_body.replace(CHANGED_INTO_PLACEMARKER_CLOSED, f'</span>')
return n_body
def apply_discord_markdown_to_body(n_body):
"""
Discord does not support <del> but it supports non-standard ~~strikethrough~~
@@ -333,6 +351,16 @@ def process_notification(n_object: NotificationContextData, datastore):
if not n_object.get('notification_urls'):
return None
n_object.update(add_rendered_diff_to_notification_vars(
notification_scan_text=n_object.get('notification_body', '')+n_object.get('notification_title', ''),
current_snapshot=n_object.get('current_snapshot'),
prev_snapshot=n_object.get('prev_snapshot'),
# Should always be false for 'text' mode or its too hard to read
# But otherwise, this could be some setting
word_diff=False if requested_output_format_original == 'text' else True,
)
)
with (apprise.LogCapture(level=apprise.logging.DEBUG) as logs):
for url in n_object['notification_urls']:
+62 -16
View File
@@ -115,7 +115,6 @@ class NotificationContextData(dict):
super().__setitem__(key, value)
def timestamp_to_localtime(timestamp):
# Format the date using locale-aware formatting with timezone
dt = datetime.datetime.fromtimestamp(int(timestamp))
@@ -134,21 +133,70 @@ def timestamp_to_localtime(timestamp):
return formatted_date
def set_basic_notification_vars(snapshot_contents, current_snapshot, prev_snapshot, watch, triggered_text, timestamp_changed=None):
now = time.time()
def add_rendered_diff_to_notification_vars(notification_scan_text:str, prev_snapshot:str, current_snapshot:str, word_diff:bool):
"""
Efficiently renders only the diff placeholders that are actually used in the notification text.
Scans the notification template for diff placeholder usage (diff, diff_added, diff_clean, etc.)
and only renders those specific variants, avoiding expensive render_diff() calls for unused placeholders.
Uses LRU caching to avoid duplicate renders when multiple placeholders share the same arguments.
Args:
notification_scan_text: The notification template text to scan for placeholders
prev_snapshot: Previous version of content for diff comparison
current_snapshot: Current version of content for diff comparison
word_diff: Whether to use word-level (True) or line-level (False) diffing
Returns:
dict: Only the diff placeholders that were found in notification_scan_text, with rendered content
"""
from changedetectionio import diff
import re
from functools import lru_cache
now = time.time()
# Define specifications for each diff variant
diff_specs = {
'diff': {'word_diff': word_diff},
'diff_clean': {'word_diff': word_diff, 'include_change_type_prefix': False},
'diff_added': {'word_diff': word_diff, 'include_removed': False},
'diff_added_clean': {'word_diff': word_diff, 'include_removed': False, 'include_change_type_prefix': False},
'diff_full': {'word_diff': word_diff, 'include_equal': True},
'diff_full_clean': {'word_diff': word_diff, 'include_equal': True, 'include_change_type_prefix': False},
'diff_patch': {'word_diff': word_diff, 'patch_format': True},
'diff_removed': {'word_diff': word_diff, 'include_added': False},
'diff_removed_clean': {'word_diff': word_diff, 'include_added': False, 'include_change_type_prefix': False},
}
# Memoize render_diff to avoid duplicate renders with same kwargs
@lru_cache(maxsize=4)
def cached_render(kwargs_tuple):
return diff.render_diff(prev_snapshot, current_snapshot, **dict(kwargs_tuple))
ret = {}
rendered_count = 0
# Only check and render diff keys that exist in NotificationContextData
for key in NotificationContextData().keys():
if key.startswith('diff') and key in diff_specs:
# Check if this placeholder is actually used in the notification text
pattern = rf"(?<![A-Za-z0-9_]){re.escape(key)}(?![A-Za-z0-9_])"
if re.search(pattern, notification_scan_text, re.IGNORECASE):
kwargs = diff_specs[key]
# Convert dict to sorted tuple for cache key (handles duplicate kwarg combinations)
ret[key] = cached_render(tuple(sorted(kwargs.items())))
rendered_count += 1
if rendered_count:
logger.trace(f"Rendered {rendered_count} diff placeholder(s) {sorted(ret.keys())} in {time.time() - now:.3f}s")
return ret
def set_basic_notification_vars(current_snapshot, prev_snapshot, watch, triggered_text, timestamp_changed=None):
n_object = {
'current_snapshot': snapshot_contents,
'diff': diff.render_diff(prev_snapshot, current_snapshot),
'diff_clean': diff.render_diff(prev_snapshot, current_snapshot, include_change_type_prefix=False),
'diff_added': diff.render_diff(prev_snapshot, current_snapshot, include_removed=False),
'diff_added_clean': diff.render_diff(prev_snapshot, current_snapshot, include_removed=False, include_change_type_prefix=False),
'diff_full': diff.render_diff(prev_snapshot, current_snapshot, include_equal=True),
'diff_full_clean': diff.render_diff(prev_snapshot, current_snapshot, include_equal=True, include_change_type_prefix=False),
'diff_patch': diff.render_diff(prev_snapshot, current_snapshot, patch_format=True),
'diff_removed': diff.render_diff(prev_snapshot, current_snapshot, include_added=False),
'diff_removed_clean': diff.render_diff(prev_snapshot, current_snapshot, include_added=False, include_change_type_prefix=False),
'current_snapshot': current_snapshot,
'prev_snapshot': prev_snapshot,
'screenshot': watch.get_screenshot() if watch and watch.get('notification_screenshot') else None,
'change_datetime': timestamp_to_localtime(timestamp_changed) if timestamp_changed else None,
'triggered_text': triggered_text,
@@ -163,7 +211,6 @@ def set_basic_notification_vars(snapshot_contents, current_snapshot, prev_snapsh
if watch:
n_object.update(watch.extra_notification_token_values())
logger.trace(f"Main rendered notification placeholders (diff_added etc) calculated in {time.time() - now:.3f}s")
return n_object
class NotificationService:
@@ -220,8 +267,7 @@ class NotificationService:
current_snapshot = watch.get_history_snapshot(timestamp=dates[date_index_to])
n_object.update(set_basic_notification_vars(snapshot_contents=snapshot_contents,
current_snapshot=current_snapshot,
n_object.update(set_basic_notification_vars(current_snapshot=current_snapshot,
prev_snapshot=prev_snapshot,
watch=watch,
triggered_text=triggered_text,
+379 -6
View File
@@ -2,6 +2,7 @@ import pluggy
import os
import importlib
import sys
from loguru import logger
# Global plugin namespace for changedetection.io
PLUGIN_NAMESPACE = "changedetectionio"
@@ -16,15 +17,94 @@ class ChangeDetectionSpec:
@hookspec
def ui_edit_stats_extras(watch):
"""Return HTML content to add to the stats tab in the edit view.
Args:
watch: The watch object being edited
Returns:
str: HTML content to be inserted in the stats tab
"""
pass
@hookspec
def register_content_fetcher(self):
"""Return a tuple of (fetcher_name, fetcher_class) for content fetcher plugins.
The fetcher_name should start with 'html_' and the fetcher_class
should inherit from changedetectionio.content_fetchers.base.Fetcher
Returns:
tuple: (str: fetcher_name, class: fetcher_class)
"""
pass
@hookspec
def fetcher_status_icon(fetcher_name):
"""Return status icon HTML attributes for a content fetcher.
Args:
fetcher_name: The name of the fetcher (e.g., 'html_webdriver', 'html_js_zyte')
Returns:
str: HTML string containing <img> tags or other status icon elements
Empty string if no custom status icon is needed
"""
pass
@hookspec
def plugin_static_path(self):
"""Return the path to the plugin's static files directory.
Returns:
str: Absolute path to the plugin's static directory, or None if no static files
"""
pass
@hookspec
def get_itemprop_availability_override(self, content, fetcher_name, fetcher_instance, url):
"""Provide custom implementation of get_itemprop_availability for a specific fetcher.
This hook allows plugins to provide their own product availability detection
when their fetcher is being used. This is called as a fallback when the built-in
method doesn't find good data.
Args:
content: The HTML/text content to parse
fetcher_name: The name of the fetcher being used (e.g., 'html_js_zyte')
fetcher_instance: The fetcher instance that generated the content
url: The URL being watched/checked
Returns:
dict or None: Dictionary with availability data:
{
'price': float or None,
'availability': str or None, # e.g., 'in stock', 'out of stock'
'currency': str or None, # e.g., 'USD', 'EUR'
}
Or None if this plugin doesn't handle this fetcher or couldn't extract data
"""
pass
@hookspec
def plugin_settings_tab(self):
"""Return settings tab information for this plugin.
This hook allows plugins to add their own settings tab to the settings page.
Settings will be saved to a separate JSON file in the datastore directory.
Returns:
dict or None: Dictionary with settings tab information:
{
'plugin_id': str, # Unique identifier (e.g., 'zyte_fetcher')
'tab_label': str, # Display name for tab (e.g., 'Zyte Fetcher')
'form_class': Form, # WTForms Form class for the settings
'template_path': str, # Optional: path to Jinja2 template (relative to plugin)
# If not provided, a default form renderer will be used
}
Or None if this plugin doesn't provide settings
"""
pass
# Set up Plugin Manager
plugin_manager = pluggy.PluginManager(PLUGIN_NAMESPACE)
@@ -65,18 +145,311 @@ load_plugins_from_directories()
# Discover installed plugins from external packages (if any)
plugin_manager.load_setuptools_entrypoints(PLUGIN_NAMESPACE)
# Function to inject datastore into plugins that need it
def inject_datastore_into_plugins(datastore):
"""Inject the global datastore into plugins that need access to settings.
This should be called after plugins are loaded and datastore is initialized.
Args:
datastore: The global ChangeDetectionStore instance
"""
for plugin_name, plugin_obj in plugin_manager.list_name_plugin():
# Check if plugin has datastore attribute and it's not set
if hasattr(plugin_obj, 'datastore'):
if plugin_obj.datastore is None:
plugin_obj.datastore = datastore
logger.debug(f"Injected datastore into plugin: {plugin_name}")
# Function to register built-in fetchers - called later from content_fetchers/__init__.py
def register_builtin_fetchers():
"""Register built-in content fetchers as internal plugins
This is called from content_fetchers/__init__.py after all fetchers are imported
to avoid circular import issues.
"""
from changedetectionio.content_fetchers import requests, playwright, puppeteer, webdriver_selenium
# Register each built-in fetcher plugin
if hasattr(requests, 'requests_plugin'):
plugin_manager.register(requests.requests_plugin, 'builtin_requests')
if hasattr(playwright, 'playwright_plugin'):
plugin_manager.register(playwright.playwright_plugin, 'builtin_playwright')
if hasattr(puppeteer, 'puppeteer_plugin'):
plugin_manager.register(puppeteer.puppeteer_plugin, 'builtin_puppeteer')
if hasattr(webdriver_selenium, 'webdriver_selenium_plugin'):
plugin_manager.register(webdriver_selenium.webdriver_selenium_plugin, 'builtin_webdriver_selenium')
# Helper function to collect UI stats extras from all plugins
def collect_ui_edit_stats_extras(watch):
"""Collect and combine HTML content from all plugins that implement ui_edit_stats_extras"""
extras_content = []
# Get all plugins that implement the ui_edit_stats_extras hook
results = plugin_manager.hook.ui_edit_stats_extras(watch=watch)
# If we have results, add them to our content
if results:
for result in results:
if result: # Skip empty results
extras_content.append(result)
return "\n".join(extras_content) if extras_content else ""
return "\n".join(extras_content) if extras_content else ""
def collect_fetcher_status_icons(fetcher_name):
"""Collect status icon data from all plugins
Args:
fetcher_name: The name of the fetcher (e.g., 'html_webdriver', 'html_js_zyte')
Returns:
dict or None: Icon data dictionary from first matching plugin, or None
"""
# Get status icon data from plugins
results = plugin_manager.hook.fetcher_status_icon(fetcher_name=fetcher_name)
# Return first non-None result
if results:
for result in results:
if result and isinstance(result, dict):
return result
return None
def get_itemprop_availability_from_plugin(content, fetcher_name, fetcher_instance, url):
"""Get itemprop availability data from plugins as a fallback.
This is called when the built-in get_itemprop_availability doesn't find good data.
Args:
content: The HTML/text content to parse
fetcher_name: The name of the fetcher being used (e.g., 'html_js_zyte')
fetcher_instance: The fetcher instance that generated the content
url: The URL being watched (watch.link - includes Jinja2 evaluation)
Returns:
dict or None: Availability data dictionary from first matching plugin, or None
"""
# Get availability data from plugins
results = plugin_manager.hook.get_itemprop_availability_override(
content=content,
fetcher_name=fetcher_name,
fetcher_instance=fetcher_instance,
url=url
)
# Return first non-None result with actual data
if results:
for result in results:
if result and isinstance(result, dict):
# Check if the result has any meaningful data
if result.get('price') is not None or result.get('availability'):
return result
return None
def get_active_plugins():
"""Get a list of active plugins with their descriptions.
Returns:
list: List of dictionaries with plugin information:
[
{'name': 'plugin_name', 'description': 'Plugin description'},
...
]
"""
active_plugins = []
# Get all registered plugins
for plugin_name, plugin_obj in plugin_manager.list_name_plugin():
# Skip built-in plugins (they start with 'builtin_')
if plugin_name.startswith('builtin_'):
continue
# Get plugin description if available
description = None
if hasattr(plugin_obj, '__doc__') and plugin_obj.__doc__:
description = plugin_obj.__doc__.strip().split('\n')[0] # First line only
elif hasattr(plugin_obj, 'description'):
description = plugin_obj.description
# Try to get a friendly name from the plugin
friendly_name = plugin_name
if hasattr(plugin_obj, 'name'):
friendly_name = plugin_obj.name
active_plugins.append({
'name': friendly_name,
'description': description or 'No description available'
})
return active_plugins
def get_fetcher_capabilities(watch, datastore):
"""Get capability flags for a watch's fetcher.
Args:
watch: The watch object/dict
datastore: The datastore to resolve 'system' fetcher
Returns:
dict: Dictionary with capability flags:
{
'supports_browser_steps': bool,
'supports_screenshots': bool,
'supports_xpath_element_data': bool
}
"""
# Get the fetcher name from watch
fetcher_name = watch.get('fetch_backend', 'system')
# Resolve 'system' to actual fetcher
if fetcher_name == 'system':
fetcher_name = datastore.data['settings']['application'].get('fetch_backend', 'html_requests')
# Get the fetcher class
from changedetectionio import content_fetchers
# Try to get from built-in fetchers first
if hasattr(content_fetchers, fetcher_name):
fetcher_class = getattr(content_fetchers, fetcher_name)
return {
'supports_browser_steps': getattr(fetcher_class, 'supports_browser_steps', False),
'supports_screenshots': getattr(fetcher_class, 'supports_screenshots', False),
'supports_xpath_element_data': getattr(fetcher_class, 'supports_xpath_element_data', False)
}
# Try to get from plugin-provided fetchers
# Query all plugins for registered fetchers
plugin_fetchers = plugin_manager.hook.register_content_fetcher()
for fetcher_registration in plugin_fetchers:
if fetcher_registration:
name, fetcher_class = fetcher_registration
if name == fetcher_name:
return {
'supports_browser_steps': getattr(fetcher_class, 'supports_browser_steps', False),
'supports_screenshots': getattr(fetcher_class, 'supports_screenshots', False),
'supports_xpath_element_data': getattr(fetcher_class, 'supports_xpath_element_data', False)
}
# Default: no capabilities
return {
'supports_browser_steps': False,
'supports_screenshots': False,
'supports_xpath_element_data': False
}
def get_plugin_settings_tabs():
"""Get all plugin settings tabs.
Returns:
list: List of dictionaries with plugin settings tab information:
[
{
'plugin_id': str,
'tab_label': str,
'form_class': Form,
'description': str
},
...
]
"""
tabs = []
results = plugin_manager.hook.plugin_settings_tab()
for result in results:
if result and isinstance(result, dict):
# Validate required fields
if 'plugin_id' in result and 'tab_label' in result and 'form_class' in result:
tabs.append(result)
else:
logger.warning(f"Invalid plugin settings tab spec: {result}")
return tabs
def load_plugin_settings(datastore_path, plugin_id):
"""Load settings for a specific plugin from JSON file.
Args:
datastore_path: Path to the datastore directory
plugin_id: Unique identifier for the plugin (e.g., 'zyte_fetcher')
Returns:
dict: Plugin settings, or empty dict if file doesn't exist
"""
import json
settings_file = os.path.join(datastore_path, f"{plugin_id}.json")
if not os.path.exists(settings_file):
return {}
try:
with open(settings_file, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
logger.error(f"Failed to load settings for plugin '{plugin_id}': {e}")
return {}
def save_plugin_settings(datastore_path, plugin_id, settings):
"""Save settings for a specific plugin to JSON file.
Args:
datastore_path: Path to the datastore directory
plugin_id: Unique identifier for the plugin (e.g., 'zyte_fetcher')
settings: Dictionary of settings to save
Returns:
bool: True if save was successful, False otherwise
"""
import json
settings_file = os.path.join(datastore_path, f"{plugin_id}.json")
try:
with open(settings_file, 'w', encoding='utf-8') as f:
json.dump(settings, f, indent=2, ensure_ascii=False)
logger.info(f"Saved settings for plugin '{plugin_id}' to {settings_file}")
return True
except Exception as e:
logger.error(f"Failed to save settings for plugin '{plugin_id}': {e}")
return False
def get_plugin_template_paths():
"""Get list of plugin template directories for Jinja2 loader.
Returns:
list: List of absolute paths to plugin template directories
"""
template_paths = []
# Get all registered plugins
for plugin_name, plugin_obj in plugin_manager.list_name_plugin():
# Check if plugin has a templates directory
if hasattr(plugin_obj, '__file__'):
plugin_file = plugin_obj.__file__
elif hasattr(plugin_obj, '__module__'):
# Get the module file
module = sys.modules.get(plugin_obj.__module__)
if module and hasattr(module, '__file__'):
plugin_file = module.__file__
else:
continue
else:
continue
if plugin_file:
plugin_dir = os.path.dirname(os.path.abspath(plugin_file))
templates_dir = os.path.join(plugin_dir, 'templates')
if os.path.isdir(templates_dir):
template_paths.append(templates_dir)
logger.debug(f"Added plugin template path: {templates_dir}")
return template_paths
+2
View File
@@ -23,6 +23,7 @@ class difference_detection_processor():
def __init__(self, *args, datastore, watch_uuid, **kwargs):
super().__init__(*args, **kwargs)
self.datastore = datastore
self.watch_uuid = watch_uuid
self.watch = deepcopy(self.datastore.data['watching'].get(watch_uuid))
# Generic fetcher that should be extended (requests, playwright etc)
self.fetcher = Fetcher()
@@ -160,6 +161,7 @@ class difference_detection_processor():
request_method=request_method,
timeout=timeout,
url=url,
watch_uuid=self.watch_uuid,
)
#@todo .quit here could go on close object, so we can run JS if change-detected
+131
View File
@@ -0,0 +1,131 @@
"""
Base data extraction module for all processors.
This module handles extracting data from watch history using regex patterns
and exporting to CSV format. This is the default extractor that all processors
(text_json_diff, restock_diff, etc.) can use by default or override.
"""
import os
from loguru import logger
def render_form(watch, datastore, request, url_for, render_template, flash, redirect, extract_form=None):
"""
Render the data extraction form.
Args:
watch: The watch object
datastore: The ChangeDetectionStore instance
request: Flask request object
url_for: Flask url_for function
render_template: Flask render_template function
flash: Flask flash function
redirect: Flask redirect function
extract_form: Optional pre-built extract form (for error cases)
Returns:
Rendered HTML response with the extraction form
"""
from changedetectionio import forms
uuid = watch.get('uuid')
# Use provided form or create a new one
if extract_form is None:
extract_form = forms.extractDataForm(
formdata=request.form,
data={'extract_regex': request.form.get('extract_regex', '')}
)
# Get error information for the template
screenshot_url = watch.get_screenshot()
system_uses_webdriver = datastore.data['settings']['application']['fetch_backend'] == 'html_webdriver'
is_html_webdriver = False
if (watch.get('fetch_backend') == 'system' and system_uses_webdriver) or watch.get('fetch_backend') == 'html_webdriver' or watch.get('fetch_backend', '').startswith('extra_browser_'):
is_html_webdriver = True
password_enabled_and_share_is_off = False
if datastore.data['settings']['application'].get('password') or os.getenv("SALTED_PASS", False):
password_enabled_and_share_is_off = not datastore.data['settings']['application'].get('shared_diff_access')
# Use the shared default template from processors/templates/
# Processors can override this by creating their own extract.py with custom template logic
output = render_template(
"extract.html",
uuid=uuid,
extract_form=extract_form,
watch_a=watch,
last_error=watch['last_error'],
last_error_screenshot=watch.get_error_snapshot(),
last_error_text=watch.get_error_text(),
screenshot=screenshot_url,
is_html_webdriver=is_html_webdriver,
password_enabled_and_share_is_off=password_enabled_and_share_is_off,
extra_title=f" - {watch.label} - Extract Data",
extra_stylesheets=[url_for('static_content', group='styles', filename='diff.css')],
pure_menu_fixed=False
)
return output
def process_extraction(watch, datastore, request, url_for, make_response, send_from_directory, flash, redirect, extract_form=None):
"""
Process the data extraction request and return CSV file.
Args:
watch: The watch object
datastore: The ChangeDetectionStore instance
request: Flask request object
url_for: Flask url_for function
make_response: Flask make_response function
send_from_directory: Flask send_from_directory function
flash: Flask flash function
redirect: Flask redirect function
extract_form: Optional pre-built extract form
Returns:
CSV file download response or redirect to form on error
"""
from changedetectionio import forms
uuid = watch.get('uuid')
# Use provided form or create a new one
if extract_form is None:
extract_form = forms.extractDataForm(
formdata=request.form,
data={'extract_regex': request.form.get('extract_regex', '')}
)
if not extract_form.validate():
flash("An error occurred, please see below.", "error")
# render_template needs to be imported from Flask for this to work
from flask import render_template as flask_render_template
return render_form(
watch=watch,
datastore=datastore,
request=request,
url_for=url_for,
render_template=flask_render_template,
flash=flash,
redirect=redirect,
extract_form=extract_form
)
extract_regex = request.form.get('extract_regex', '').strip()
output = watch.extract_regex_from_all_history(extract_regex)
if output:
watch_dir = os.path.join(datastore.datastore_path, uuid)
response = make_response(send_from_directory(directory=watch_dir, path=output, as_attachment=True))
response.headers['Content-type'] = 'text/csv'
response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
response.headers['Pragma'] = 'no-cache'
response.headers['Expires'] = "0"
return response
flash('No matches found while scanning all of the watch history for that RegEx.', 'error')
return redirect(url_for('ui.ui_diff.diff_history_page_extract_GET', uuid=uuid))
@@ -187,6 +187,8 @@ class perform_site_check(difference_detection_processor):
itemprop_availability = {}
# Try built-in extraction first, this will scan metadata in the HTML
try:
itemprop_availability = get_itemprop_availability(self.fetcher.content)
except MoreThanOnePriceFound as e:
@@ -198,6 +200,33 @@ class perform_site_check(difference_detection_processor):
xpath_data=self.fetcher.xpath_data
)
# If built-in extraction didn't get both price AND availability, try plugin override
# Only check plugin if this watch is using a fetcher that might provide better data
has_price = itemprop_availability.get('price') is not None
has_availability = itemprop_availability.get('availability') is not None
# @TODO !!! some setting like "Use as fallback" or "always use", "t
if not (has_price and has_availability) or True:
from changedetectionio.pluggy_interface import get_itemprop_availability_from_plugin
fetcher_name = watch.get('fetch_backend', 'html_requests')
# Only try plugin override if not using system default (which might be anything)
if fetcher_name and fetcher_name != 'system':
logger.debug("Calling extra plugins for getting item price/availability")
plugin_availability = get_itemprop_availability_from_plugin(self.fetcher.content, fetcher_name, self.fetcher, watch.link)
if plugin_availability:
# Plugin provided better data, use it
plugin_has_price = plugin_availability.get('price') is not None
plugin_has_availability = plugin_availability.get('availability') is not None
# Only use plugin data if it's actually better than what we have
if plugin_has_price or plugin_has_availability:
itemprop_availability = plugin_availability
logger.info(f"Using plugin-provided availability data for fetcher '{fetcher_name}' (built-in had price={has_price}, availability={has_availability}; plugin has price={plugin_has_price}, availability={plugin_has_availability})")
if not plugin_availability:
logger.debug("No item price/availability from plugins")
# Something valid in get_itemprop_availability() by scraping metadata ?
if itemprop_availability.get('price') or itemprop_availability.get('availability'):
# Store for other usage
@@ -0,0 +1,47 @@
{% extends 'base.html' %}
{% from '_helpers.html' import render_field, render_checkbox_field, render_button %}
{% block content %}
<div class="tabs">
<ul>
{% if last_error_text %}<li class="tab" id="error-text-tab"><a href="{{ url_for('ui.ui_diff.diff_history_page', uuid=uuid)}}#error-text">Error Text</a></li> {% endif %}
{% if last_error_screenshot %}<li class="tab" id="error-screenshot-tab"><a href="{{ url_for('ui.ui_diff.diff_history_page', uuid=uuid)}}#error-screenshot">Error Screenshot</a></li> {% endif %}
<li class="tab" id=""><a href="{{ url_for('ui.ui_diff.diff_history_page', uuid=uuid)}}#text">Text</a></li>
<li class="tab" id="screenshot-tab"><a href="{{ url_for('ui.ui_diff.diff_history_page', uuid=uuid)}}#screenshot">Screenshot</a></li>
<li class="tab active" id="extract-tab"><a href="{{ url_for('ui.ui_diff.diff_history_page_extract_GET', uuid=uuid)}}">Extract Data</a></li>
</ul>
</div>
<div id="diff-ui">
<div class="xxxxtab-pane-inner" id="extract">
<form id="extract-data-form" class="pure-form pure-form-stacked edit-form" action="{{ url_for('ui.ui_diff.diff_history_page_extract_POST', uuid=uuid) }}" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<p>This tool will extract text data from all of the watch history.</p>
<div class="pure-control-group">
{{ render_field(extract_form.extract_regex) }}
<span class="pure-form-message-inline">
A <strong>RegEx</strong> is a pattern that identifies exactly which part inside of the text that you want to extract.<br>
<p>
For example, to extract only the numbers from text &dash;<br>
<strong>Raw text</strong>: <code>Temperature <span style="color: red">5.5</span>°C in Sydney</code><br>
<strong>RegEx to extract:</strong> <code>Temperature <span style="color: red">([0-9\.]+)</span></code><br>
</p>
<p>
<a href="https://RegExr.com/">Be sure to test your RegEx here.</a>
</p>
<p>
Each RegEx group bracket <code>()</code> will be in its own column, the first column value is always the date.
</p>
</span>
</div>
<div class="pure-control-group">
{{ render_button(extract_form.extract_submit_button) }}
</div>
</form>
</div>
</div>
{% endblock %}
@@ -45,6 +45,7 @@ def prepare_filter_prevew(datastore, watch_uuid, form_data):
text_before_filter = ''
trigger_line_numbers = []
ignore_line_numbers = []
blocked_line_numbers = []
tmp_watch = deepcopy(datastore.data['watching'].get(watch_uuid))
@@ -104,14 +105,23 @@ def prepare_filter_prevew(datastore, watch_uuid, form_data):
except Exception as e:
text_before_filter = f"Error: {str(e)}"
try:
blocked_line_numbers = html_tools.strip_ignore_text(content=text_after_filter,
wordlist=tmp_watch.get('text_should_not_be_present', []) + datastore.data['settings']['application'].get('text_should_not_be_present', []),
mode='line numbers'
)
except Exception as e:
text_before_filter = f"Error: {str(e)}"
logger.trace(f"Parsed in {time.time() - now:.3f}s")
return ({
'after_filter': text_after_filter,
'before_filter': text_before_filter.decode('utf-8') if isinstance(text_before_filter, bytes) else text_before_filter,
'duration': time.time() - now,
'trigger_line_numbers': trigger_line_numbers,
'ignore_line_numbers': ignore_line_numbers,
'after_filter': text_after_filter,
'before_filter': text_before_filter.decode('utf-8') if isinstance(text_before_filter, bytes) else text_before_filter,
'blocked_line_numbers': blocked_line_numbers,
'duration': time.time() - now,
'ignore_line_numbers': ignore_line_numbers,
'trigger_line_numbers': trigger_line_numbers,
})
@@ -0,0 +1,232 @@
"""
History/diff rendering for text_json_diff processor.
This module handles the visualization of text/HTML/JSON changes by rendering
a side-by-side or unified diff view with syntax highlighting and change markers.
"""
import os
import time
from loguru import logger
from changedetectionio import diff, strtobool
from changedetectionio.diff import (
REMOVED_STYLE, ADDED_STYLE, REMOVED_INNER_STYLE, ADDED_INNER_STYLE,
REMOVED_PLACEMARKER_OPEN, REMOVED_PLACEMARKER_CLOSED,
ADDED_PLACEMARKER_OPEN, ADDED_PLACEMARKER_CLOSED,
CHANGED_PLACEMARKER_OPEN, CHANGED_PLACEMARKER_CLOSED,
CHANGED_INTO_PLACEMARKER_OPEN, CHANGED_INTO_PLACEMARKER_CLOSED
)
from changedetectionio.notification.handler import apply_html_color_to_body
def build_diff_cell_visualizer(content, resolution=100):
"""
Build a visual cell grid for the diff visualizer.
Analyzes the content for placemarkers indicating changes and creates a
grid of cells representing the document, with each cell marked as:
- 'deletion' for removed content
- 'insertion' for added content
- 'mixed' for cells containing both deletions and insertions
- empty string for cells with no changes
Args:
content: The diff content with placemarkers
resolution: Number of cells to create (default 100)
Returns:
List of dicts with 'class' key for each cell's CSS class
"""
if not content:
return [{'class': ''} for _ in range(resolution)]
now = time.time()
# Work with character positions for better accuracy
content_length = len(content)
if content_length == 0:
return [{'class': ''} for _ in range(resolution)]
chars_per_cell = max(1, content_length / resolution)
# Track change type for each cell
cell_data = {}
# Placemarkers to detect
change_markers = {
REMOVED_PLACEMARKER_OPEN: 'deletion',
ADDED_PLACEMARKER_OPEN: 'insertion',
CHANGED_PLACEMARKER_OPEN: 'deletion',
CHANGED_INTO_PLACEMARKER_OPEN: 'insertion',
}
# Find all occurrences of each marker
for marker, change_type in change_markers.items():
pos = 0
while True:
pos = content.find(marker, pos)
if pos == -1:
break
# Calculate which cell this marker falls into
cell_index = min(int(pos / chars_per_cell), resolution - 1)
if cell_index not in cell_data:
cell_data[cell_index] = change_type
elif cell_data[cell_index] != change_type:
# Mixed changes in this cell
cell_data[cell_index] = 'mixed'
pos += len(marker)
# Build the cell list
cells = []
for i in range(resolution):
change_type = cell_data.get(i, '')
cells.append({'class': change_type})
logger.debug(f"Built diff cell visualizer: {len([c for c in cells if c['class']])} cells with changes out of {resolution} in {time.time() - now:.2f}s")
return cells
# Diff display preferences configuration - single source of truth
DIFF_PREFERENCES_CONFIG = {
'changesOnly': {'default': True, 'type': 'bool'},
'ignoreWhitespace': {'default': False, 'type': 'bool'},
'removed': {'default': True, 'type': 'bool'},
'added': {'default': True, 'type': 'bool'},
'replaced': {'default': True, 'type': 'bool'},
'type': {'default': 'diffLines', 'type': 'value'},
}
def render(watch, datastore, request, url_for, render_template, flash, redirect, extract_form=None):
"""
Render the history/diff view for text/JSON/HTML changes.
Args:
watch: The watch object
datastore: The ChangeDetectionStore instance
request: Flask request object
url_for: Flask url_for function
render_template: Flask render_template function
flash: Flask flash function
redirect: Flask redirect function
extract_form: Optional pre-built extract form (for error cases)
Returns:
Rendered HTML response
"""
from changedetectionio import forms
uuid = watch.get('uuid')
extra_stylesheets = [url_for('static_content', group='styles', filename='diff.css')]
# Use provided form or create a new one
if extract_form is None:
extract_form = forms.extractDataForm(formdata=request.form,
data={'extract_regex': request.form.get('extract_regex', '')}
)
history = watch.history
dates = list(history.keys())
# If a "from_version" was requested, then find it (or the closest one)
# Also set "from version" to be the closest version to the one that was last viewed.
best_last_viewed_timestamp = watch.get_from_version_based_on_last_viewed
from_version_timestamp = best_last_viewed_timestamp if best_last_viewed_timestamp else dates[-2]
from_version = request.args.get('from_version', from_version_timestamp )
# Use the current one if nothing was specified
to_version = request.args.get('to_version', str(dates[-1]))
try:
to_version_file_contents = watch.get_history_snapshot(timestamp=to_version)
except Exception as e:
logger.error(f"Unable to read watch history to-version for version {to_version}: {str(e)}")
to_version_file_contents = f"Unable to read to-version at {to_version}.\n"
try:
from_version_file_contents = watch.get_history_snapshot(timestamp=from_version)
except Exception as e:
logger.error(f"Unable to read watch history from-version for version {from_version}: {str(e)}")
from_version_file_contents = f"Unable to read to-version {from_version}.\n"
screenshot_url = watch.get_screenshot()
system_uses_webdriver = datastore.data['settings']['application']['fetch_backend'] == 'html_webdriver'
is_html_webdriver = False
if (watch.get('fetch_backend') == 'system' and system_uses_webdriver) or watch.get('fetch_backend') == 'html_webdriver' or watch.get('fetch_backend', '').startswith('extra_browser_'):
is_html_webdriver = True
password_enabled_and_share_is_off = False
if datastore.data['settings']['application'].get('password') or os.getenv("SALTED_PASS", False):
password_enabled_and_share_is_off = not datastore.data['settings']['application'].get('shared_diff_access')
datastore.set_last_viewed(uuid, time.time())
# Parse diff preferences from request using config as single source of truth
# Check if this is a user submission (any diff pref param exists in query string)
user_submitted = any(key in request.args for key in DIFF_PREFERENCES_CONFIG.keys())
diff_prefs = {}
for key, config in DIFF_PREFERENCES_CONFIG.items():
if user_submitted:
# User submitted form - missing checkboxes are explicitly OFF
if config['type'] == 'bool':
diff_prefs[key] = strtobool(request.args.get(key, 'off'))
else:
diff_prefs[key] = request.args.get(key, config['default'])
else:
# Initial load - use defaults from config
diff_prefs[key] = config['default']
content = diff.render_diff(previous_version_file_contents=from_version_file_contents,
newest_version_file_contents=to_version_file_contents,
include_replaced=diff_prefs['replaced'],
include_added=diff_prefs['added'],
include_removed=diff_prefs['removed'],
include_equal=diff_prefs['changesOnly'],
ignore_junk=diff_prefs['ignoreWhitespace'],
word_diff=diff_prefs['type'] == 'diffWords',
)
# Build cell grid visualizer before applying HTML color (so we can detect placemarkers)
diff_cell_grid = build_diff_cell_visualizer(content)
content = apply_html_color_to_body(n_body=content)
offscreen_content = render_template("diff-offscreen-options.html")
note = ''
if str(from_version) != str(dates[-2]) or str(to_version) != str(dates[-1]):
note = 'Note: You are not viewing the latest changes.'
output = render_template("diff.html",
#initial_scroll_line_number=100,
bottom_horizontal_offscreen_contents=offscreen_content,
content=content,
current_diff_url=watch['url'],
diff_cell_grid=diff_cell_grid,
diff_prefs=diff_prefs,
extra_classes='difference-page',
extra_stylesheets=extra_stylesheets,
extra_title=f" - {watch.label} - History",
extract_form=extract_form,
from_version=str(from_version),
is_html_webdriver=is_html_webdriver,
last_error=watch['last_error'],
last_error_screenshot=watch.get_error_snapshot(),
last_error_text=watch.get_error_text(),
newest=to_version_file_contents,
newest_version_timestamp=dates[-1],
note=note,
password_enabled_and_share_is_off=password_enabled_and_share_is_off,
pure_menu_fixed=False,
screenshot=screenshot_url,
to_version=str(to_version),
uuid=uuid,
versions=dates, # All except current/last
watch_a=watch,
)
return output
@@ -7,11 +7,10 @@ import re
import urllib3
from changedetectionio.conditions import execute_ruleset_against_all_plugins
from changedetectionio.diff import ADDED_PLACEMARKER_OPEN
from changedetectionio.processors import difference_detection_processor
from changedetectionio.html_tools import PERL_STYLE_REGEX, cdata_in_document_to_text, TRANSLATE_WHITESPACE_TABLE
from changedetectionio import html_tools, content_fetchers
from changedetectionio.blueprint.price_data_follower import PRICE_DATA_TRACK_ACCEPT, PRICE_DATA_TRACK_REJECT
from changedetectionio.blueprint.price_data_follower import PRICE_DATA_TRACK_ACCEPT
from loguru import logger
from changedetectionio.processors.magic import guess_stream_type
@@ -468,6 +467,7 @@ class perform_site_check(difference_detection_processor):
c = ChecksumCalculator.calculate(text_content_before_ignored_filter, ignore_whitespace=True)
return False, {'previous_md5': c}, text_content_before_ignored_filter.encode('utf-8')
# === EMPTY PAGE CHECK ===
empty_pages_are_a_change = self.datastore.data['settings']['application'].get('empty_pages_are_a_change', False)
if not stream_content_type.is_json and not empty_pages_are_a_change and len(stripped_text.strip()) == 0:
@@ -584,7 +584,6 @@ class perform_site_check(difference_detection_processor):
include_added=watch.get('filter_text_added', True),
include_removed=watch.get('filter_text_removed', True),
include_replaced=watch.get('filter_text_replaced', True),
line_feed_sep="\n",
include_change_type_prefix=False
)
+1 -1
View File
@@ -5,7 +5,7 @@ from blinker import signal
def register_watch_operation_handlers(socketio, datastore):
"""Register Socket.IO event handlers for watch operations"""
@socketio.on('watch_operation')
def handle_watch_operation(data):
"""Handle watch operations like pause, mute, recheck via Socket.IO"""
@@ -32,11 +32,31 @@ class SignalHandler:
watch_favicon_bumped_signal = signal('watch_favicon_bump')
watch_favicon_bumped_signal.connect(self.handle_watch_bumped_favicon_signal, weak=False)
watch_small_status_comment_signal = signal('watch_small_status_comment')
watch_small_status_comment_signal.connect(self.handle_watch_small_status_update, weak=False)
# Connect to the notification_event signal
notification_event_signal = signal('notification_event')
notification_event_signal.connect(self.handle_notification_event, weak=False)
logger.info("SignalHandler: Connected to notification_event signal")
def handle_watch_small_status_update(self, *args, **kwargs):
"""Small simple status update, for example 'Connecting...'"""
watch_uuid = kwargs.get('watch_uuid')
status = kwargs.get('status')
if watch_uuid and status:
logger.debug(f"Socket.IO: Received watch small status update '{status}' for UUID {watch_uuid}")
# Emit the status update to all connected clients
self.socketio_instance.emit("watch_small_status_comment", {
"uuid": watch_uuid,
"status": status,
"event_timestamp": time.time()
})
def handle_signal(self, *args, **kwargs):
logger.trace(f"SignalHandler: Signal received with {len(args)} args and {len(kwargs)} kwargs")
# Safely extract the watch UUID from kwargs
+132 -45
View File
@@ -1,11 +1,92 @@
function setupDiffNavigation() {
var $fromSelect = $('#diff-from-version');
var $toSelect = $('#diff-to-version');
var $fromSelected = $fromSelect.find('option:selected');
var $toSelected = $toSelect.find('option:selected');
if ($fromSelected.length && $toSelected.length) {
// Find the previous pair (move both back one position)
var $prevFrom = $fromSelected.prev();
var $prevTo = $toSelected.prev();
// Find the next pair (move both forward one position)
var $nextFrom = $fromSelected.next();
var $nextTo = $toSelected.next();
// Build URL with current diff preferences
var currentParams = new URLSearchParams(window.location.search);
// Previous button: only show if both can move back
if ($prevFrom.length && $prevTo.length) {
currentParams.set('from_version', $prevFrom.val());
currentParams.set('to_version', $prevTo.val());
$('#btn-previous').attr('href', '?' + currentParams.toString());
} else {
$('#btn-previous').remove();
}
// Next button: only show if both can move forward
if ($nextFrom.length && $nextTo.length) {
currentParams.set('from_version', $nextFrom.val());
currentParams.set('to_version', $nextTo.val());
$('#btn-next').attr('href', '?' + currentParams.toString());
} else {
$('#btn-next').remove();
}
}
// Keyboard navigation
window.addEventListener('keydown', function (event) {
// Don't trigger if user is typing in an input field
if (event.target.tagName === 'INPUT' || event.target.tagName === 'TEXTAREA' || event.target.tagName === 'SELECT') {
return;
}
var $fromSelected = $fromSelect.find('option:selected');
var $toSelected = $toSelect.find('option:selected');
if ($fromSelected.length && $toSelected.length) {
if (event.key === 'ArrowLeft') {
var $prevFrom = $fromSelected.prev();
var $prevTo = $toSelected.prev();
if ($prevFrom.length && $prevTo.length) {
var prevHref = $('#btn-previous').attr('href');
if (prevHref) {
event.preventDefault();
window.location.href = prevHref;
}
}
} else if (event.key === 'ArrowRight') {
var $nextFrom = $fromSelected.next();
var $nextTo = $toSelected.next();
if ($nextFrom.length && $nextTo.length) {
var nextHref = $('#btn-next').attr('href');
if (nextHref) {
event.preventDefault();
window.location.href = nextHref;
}
}
}
}
}, false);
}
$(document).ready(function () {
$('.needs-localtime').each(function () {
for (var option of this.options) {
var dateObject = new Date(option.value * 1000);
option.label = dateObject.toLocaleString(undefined, {dateStyle: "full", timeStyle: "medium"});
var formattedDate = dateObject.toLocaleString(undefined, {dateStyle: "full", timeStyle: "medium"});
// Preserve any existing text in the label (like "(Previous)" or "(Current)")
var existingText = option.text.replace(option.value, '').trim();
option.label = existingText ? formattedDate + ' ' + existingText : formattedDate;
}
});
// Setup keyboard navigation for diff versions
if ($('#diff-from-version').length && $('#diff-to-version').length) {
setupDiffNavigation();
}
// Load it when the #screenshot tab is in use, so we dont give a slow experience when waiting for the text diff to load
window.addEventListener('hashchange', function (e) {
toggle(location.hash);
@@ -27,16 +108,51 @@ $(document).ready(function () {
}
}
const article = $('.highlightable-filter')[0];
const article = $('#difference')[0];
// We could also add the 'touchend' event for touch devices, but since
// most iOS/Android browsers already show a dialog when you select
// text (often with a Share option) we'll skip that
article.addEventListener('mouseup', dragTextHandler, false);
article.addEventListener('mousedown', clean, false);
if (article) {
article.addEventListener('mousedown', clean, false);
}
// Because they might 'mouse up' outside the article but on the page
const d_page = $(".difference-page")[0]
if (d_page ) {
d_page.addEventListener('mouseup', dragTextHandler, false);
}
$('#highlightSnippetActions a').bind('click', function (e) {
if (!window.getSelection().toString().trim().length) {
alert('Oops no text selected!');
return;
}
$.ajax({
type: "POST",
url: highlight_submit_ignore_url,
data: {'mode': $(this).data('mode'), 'selection': window.getSelection().toString()},
statusCode: {
400: function () {
// More than likely the CSRF token was lost when the server restarted
alert("There was a problem processing the request, please reload the page.");
}
}
}).done(function (data) {
// @todo some feedback
alert("'Ignore' Filters for this watch were updated.")
clean();
}).fail(function (data) {
console.log(data);
alert('There was an error communicating with the server.');
})
});
function clean(event) {
$("#highlightSnippet").remove();
$('#bottom-horizontal-offscreen').hide();
}
// Listen for Escape key press
@@ -51,46 +167,9 @@ $(document).ready(function () {
// Check if any text was selected
if (window.getSelection().toString().length > 0) {
// Find out how much (if any) user has scrolled
var scrollTop = (window.pageYOffset !== undefined) ? window.pageYOffset : (document.documentElement || document.body.parentNode || document.body).scrollTop;
// Get cursor position
const posX = event.clientX;
const posY = event.clientY + 20 + scrollTop;
// Append HTML to the body, create the "Tweet Selection" dialog
document.body.insertAdjacentHTML('beforeend', '<div id="highlightSnippet" style="position: absolute; top: ' + posY + 'px; left: ' + posX + 'px;"><div class="pure-form-message-inline" style="font-size: 70%">Ignore any change on any line which contains the selected text.</div><br><a data-mode="exact" href="javascript:void(0);" class="pure-button button-secondary button-xsmall">Ignore exact text</a>&nbsp;</div>');
if (/\d/.test(window.getSelection().toString())) {
// Offer regex replacement
document.getElementById("highlightSnippet").insertAdjacentHTML('beforeend', '<a data-mode="digit-regex" href="javascript:void(0);" class="pure-button button-secondary button-xsmall">Ignore text including number changes</a>');
}
$('#highlightSnippet a').bind('click', function (e) {
if(!window.getSelection().toString().trim().length) {
alert('Oops no text selected!');
return;
}
$.ajax({
type: "POST",
url: highlight_submit_ignore_url,
data: {'mode': $(this).data('mode'), 'selection': window.getSelection().toString()},
statusCode: {
400: function () {
// More than likely the CSRF token was lost when the server restarted
alert("There was a problem processing the request, please reload the page.");
}
}
}).done(function (data) {
$("#highlightSnippet").html(data)
}).fail(function (data) {
console.log(data);
alert('There was an error communicating with the server.');
});
});
$('#bottom-horizontal-offscreen').show();
} else {
clean();
}
}
@@ -100,4 +179,12 @@ $(document).ready(function () {
alert('Error - You are trying to compare the same version.');
}
});
// Auto-submit form on change of any input elements (checkboxes, radio buttons, dropdowns)
$('#diff-form').on('change', 'input[type="checkbox"], input[type="radio"], select', function (e) {
// Check if we're trying to compare the same version before submitting
if ($('select[name=from_version]').val() !== $('select[name=to_version]').val()) {
$('#diff-form').submit();
}
});
});
+137 -100
View File
@@ -1,115 +1,152 @@
$(document).ready(function () {
var a = document.getElementById("a");
var b = document.getElementById("b");
var result = document.getElementById("result");
var inputs;
// Find all <span> elements inside pre#difference
var inputs = $('#difference span').toArray();
inputs.current = 0;
// Setup visual minimap of difference locations (cells are pre-built in Python)
var $visualizer = $('#cell-diff-jump-visualiser');
var $difference = $('#difference');
var $cells = $visualizer.find('> div');
var visualizerResolutionCells = $cells.length;
var cellHeight;
if ($difference.length && visualizerResolutionCells > 0) {
var docHeight = $difference[0].scrollHeight;
cellHeight = docHeight / visualizerResolutionCells;
// Add click handlers to pre-built cells
$cells.each(function(i) {
$(this).data('cellIndex', i);
$(this).on('click', function() {
var cellIndex = $(this).data('cellIndex');
var targetPositionInDifference = cellIndex * cellHeight;
var viewportHeight = $(window).height();
// Scroll so target is at viewport center (where eyes expect it)
window.scrollTo({
top: $difference.offset().top + targetPositionInDifference - (viewportHeight / 2),
behavior: "smooth"
});
});
});
}
$('#jump-next-diff').click(function () {
if (!inputs || inputs.length === 0) return;
var element = inputs[inputs.current];
var headerOffset = 80;
var elementPosition = element.getBoundingClientRect().top;
var offsetPosition = elementPosition - headerOffset + window.scrollY;
// Find the next change after current scroll position
var currentScrollPos = $(window).scrollTop();
var viewportHeight = $(window).height();
var currentCenter = currentScrollPos + (viewportHeight / 2);
// Add small buffer (50px) to jump past changes already near center
var searchFromPosition = currentCenter + 50;
var nextElement = null;
for (var i = 0; i < inputs.length; i++) {
var elementTop = $(inputs[i]).offset().top;
if (elementTop > searchFromPosition) {
nextElement = inputs[i];
break;
}
}
// If no element found ahead, wrap to first element
if (!nextElement) {
nextElement = inputs[0];
}
// Scroll to position the element at viewport center
var elementTop = $(nextElement).offset().top;
var targetScrollPos = elementTop - (viewportHeight / 2);
window.scrollTo({
top: offsetPosition,
top: targetScrollPos,
behavior: "smooth",
});
inputs.current++;
if (inputs.current >= inputs.length) {
inputs.current = 0;
}
});
// Track current scroll position in visualizer
function updateVisualizerPosition() {
if (!$difference.length || visualizerResolutionCells === 0) return;
var scrollTop = $(window).scrollTop();
var viewportHeight = $(window).height();
var viewportCenter = scrollTop + (viewportHeight / 2);
var differenceTop = $difference.offset().top;
var differenceHeight = $difference[0].scrollHeight;
var positionInDifference = viewportCenter - differenceTop;
// Handle edge case: if we're at max scroll, show last cell
// This prevents shorter documents from never reaching 100%
var maxScrollTop = $(document).height() - viewportHeight;
var isAtBottom = scrollTop >= maxScrollTop - 10; // 10px tolerance
// Calculate which cell we're currently viewing
var currentCell;
if (isAtBottom) {
currentCell = visualizerResolutionCells - 1;
} else {
currentCell = Math.floor(positionInDifference / cellHeight);
currentCell = Math.max(0, Math.min(currentCell, visualizerResolutionCells - 1));
}
// Remove previous active marker and add to current cell
$visualizer.find('> div').removeClass('current-position');
$visualizer.find('> div').eq(currentCell).addClass('current-position');
}
// Recalculate cellHeight on window resize
function handleResize() {
if ($difference.length) {
var docHeight = $difference[0].scrollHeight;
cellHeight = docHeight / visualizerResolutionCells;
updateVisualizerPosition();
}
}
// Debounce scroll and resize events to reduce CPU usage
$(window).on('scroll', updateVisualizerPosition.debounce(5));
$(window).on('resize', handleResize.debounce(100));
// Initial scroll to specific line if requested
if (typeof initialScrollToLineNumber !== 'undefined' && initialScrollToLineNumber !== null && $difference.length) {
// Convert line number to text position and scroll to it
var diffText = $difference.text();
var lines = diffText.split('\n');
if (initialScrollToLineNumber > 0 && initialScrollToLineNumber <= lines.length) {
// Calculate character position of the target line
var charPosition = 0;
for (var i = 0; i < initialScrollToLineNumber - 1; i++) {
charPosition += lines[i].length + 1; // +1 for newline
}
// Estimate vertical position based on average line height
var totalChars = diffText.length;
var totalHeight = $difference[0].scrollHeight;
var estimatedTop = (charPosition / totalChars) * totalHeight;
// Scroll to position with line at viewport center
var viewportHeight = $(window).height();
setTimeout(function() {
window.scrollTo({
top: $difference.offset().top + estimatedTop - (viewportHeight / 2),
behavior: "smooth"
});
}, 100); // Small delay to ensure page is fully loaded
}
}
// Initial position update
if ($difference.length && cellHeight) {
updateVisualizerPosition();
}
function changed() {
// https://github.com/kpdecker/jsdiff/issues/389
// I would love to use `{ignoreWhitespace: true}` here but it breaks the formatting
options = {
ignoreWhitespace: document.getElementById("ignoreWhitespace").checked,
};
var diff = Diff[window.diffType](a.textContent, b.textContent, options);
var fragment = document.createDocumentFragment();
for (var i = 0; i < diff.length; i++) {
if (diff[i].added && diff[i + 1] && diff[i + 1].removed) {
var swap = diff[i];
diff[i] = diff[i + 1];
diff[i + 1] = swap;
}
var node;
if (diff[i].removed) {
node = document.createElement("del");
node.classList.add("change");
const wrapper = node.appendChild(document.createElement("span"));
wrapper.appendChild(document.createTextNode(diff[i].value));
} else if (diff[i].added) {
node = document.createElement("ins");
node.classList.add("change");
const wrapper = node.appendChild(document.createElement("span"));
wrapper.appendChild(document.createTextNode(diff[i].value));
} else {
node = document.createTextNode(diff[i].value);
}
fragment.appendChild(node);
}
result.textContent = "";
result.appendChild(fragment);
// For nice mouse-over hover/title information
const removed_current_option = $('#diff-version option:selected')
if (removed_current_option) {
$('del').each(function () {
$(this).prop('title', 'Removed '+removed_current_option[0].label);
});
}
const inserted_current_option = $('#current-version option:selected')
if (removed_current_option) {
$('ins').each(function () {
$(this).prop('title', 'Inserted '+inserted_current_option[0].label);
});
}
// Set the list of possible differences to jump to
inputs = document.querySelectorAll('#diff-ui .change')
// Set the "current" diff pointer
inputs.current = 0;
// Goto diff
$('#jump-next-diff').click();
//$('#jump-next-diff').click();
}
onDiffTypeChange(
document.querySelector('#settings [name="diff_type"]:checked'),
);
changed();
a.onpaste = a.onchange = b.onpaste = b.onchange = changed;
if ("oninput" in a) {
a.oninput = b.oninput = changed;
} else {
a.onkeyup = b.onkeyup = changed;
}
function onDiffTypeChange(radio) {
window.diffType = radio.value;
// Not necessary
// document.title = "Diff " + radio.value.slice(4);
}
var radio = document.getElementsByName("diff_type");
for (var i = 0; i < radio.length; i++) {
radio[i].onchange = function (e) {
onDiffTypeChange(e.target);
changed();
};
}
document.getElementById("ignoreWhitespace").onchange = function (e) {
changed();
};
});
File diff suppressed because one or more lines are too long
+10 -11
View File
@@ -62,15 +62,12 @@
const textContent = $pre.text();
const lines = textContent.split(/\r?\n/); // Handles both \n and \r\n line endings
// Build a map of line numbers to styles
const lineStyles = {};
// Build a map of line numbers to their configuration index
const lineConfigIndex = {};
configurations.forEach(config => {
const {color, lines: lineNumbers} = config;
lineNumbers.forEach(lineNumber => {
lineStyles[lineNumber] = color;
});
});
configurations.forEach((config, index) =>
config.lines.forEach(lineNumber => lineConfigIndex[lineNumber] = index)
);
// Function to escape HTML characters
function escapeHtml(text) {
@@ -83,11 +80,12 @@
const processedLines = lines.map((line, index) => {
const lineNumber = index + 1; // Line numbers start at 1
const escapedLine = escapeHtml(line);
const color = lineStyles[lineNumber];
const configIndex = lineConfigIndex[lineNumber];
if (color) {
if (configIndex !== undefined) {
const config = configurations[configIndex];
// Wrap the line in a span with inline style
return `<span style="background-color: ${color}">${escapedLine}</span>`;
return `<span title="${config.title}" style="background-color: ${config.color}">${escapedLine}</span>`;
} else {
return escapedLine;
}
@@ -100,6 +98,7 @@
$pre.html(newContent);
});
};
$.fn.miniTabs = function (tabsConfig, options) {
const settings = {
tabClass: 'minitab',
+14 -4
View File
@@ -53,11 +53,21 @@ $(document).ready(function () {
if ($('#preview-version').length) {
setupDateWidget();
}
$('#diff-col > pre').highlightLines([
$('pre#difference').highlightLines([
{
'color': '#ee0000',
'lines': triggered_line_numbers
'color': 'var(--highlight-trigger-text-bg-color)',
'lines': triggered_line_numbers,
'title': "Triggers a change if this text appears, AND something changed in the document."
},
{
'color': 'var(--highlight-ignored-text-bg-color)',
'lines': ignored_line_numbers,
'title': "Ignored for calculating changes, but still shown."
},
{
'color': 'var(--highlight-blocked-text-bg-color)',
'lines': blocked_line_numbers,
'title': "No change-detection will occur because this text exists."
}
]);
});
+5
View File
@@ -101,6 +101,11 @@ $(document).ready(function () {
}
});
socket.on('watch_small_status_comment', function (data) {
console.log(`Socket.IO: Operation watch_small_status_comment'${data.uuid}' status ${data.status}`);
$('tr[data-watch-uuid="' + data.uuid + '"] td.last-checked .status-text').html("&nbsp;").text(data.status);
});
socket.on('notification_event', function (data) {
console.log(`Stub handler for notification_event ${data.watch_uuid}`)
});
@@ -0,0 +1,729 @@
/**
* snippet-to-image.js
* Converts selected diff content to a shareable JPEG image with metadata
*/
// Constants
const IMAGE_PADDING = 5;
const JPEG_QUALITY = 0.95;
const CANVAS_SCALE = 1;
const RENDER_DELAY_MS = 50;
/**
* Utility: Get the target URL from global watch_url or fallback to current URL
*/
function getTargetUrl() {
return (typeof watch_url !== 'undefined' && watch_url) ? watch_url : window.location.href;
}
/**
* Utility: Get formatted current date with timezone
*/
function getFormattedDate() {
return new Date().toLocaleString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
timeZoneName: 'short'
});
}
/**
* Utility: Get version comparison info from the diff selectors
*/
function getVersionInfo() {
const fromSelect = document.getElementById('diff-from-version');
const toSelect = document.getElementById('diff-to-version');
if (!fromSelect || !toSelect) {
return '';
}
const fromOption = fromSelect.options[fromSelect.selectedIndex];
const toOption = toSelect.options[toSelect.selectedIndex];
const fromLabel = fromOption ? (fromOption.getAttribute('label') || fromOption.text) : 'Unknown';
const toLabel = toOption ? (toOption.getAttribute('label') || toOption.text) : 'Unknown';
return `Change comparison from <strong>${fromLabel}</strong> to <strong>${toLabel}</strong>`;
}
/**
* Helper: Find text node containing newline in a given direction
*/
function findTextNodeWithNewline(node, searchBackwards = false) {
if (node.nodeType === Node.TEXT_NODE) {
const text = node.textContent;
const idx = searchBackwards ? text.lastIndexOf('\n') : text.indexOf('\n');
if (idx !== -1) {
return { node, offset: searchBackwards ? idx + 1 : idx };
}
} else {
const walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT);
let textNode;
while (textNode = walker.nextNode()) {
const text = textNode.textContent;
const idx = searchBackwards ? text.lastIndexOf('\n') : text.indexOf('\n');
if (idx !== -1) {
return { node: textNode, offset: searchBackwards ? idx + 1 : idx };
}
}
}
return null;
}
/**
* Helper: Walk through siblings in a given direction to find line boundary
*/
function findLineBoundary(node, container, searchBackwards = false) {
let currentNode = node;
while (currentNode && currentNode !== container) {
const sibling = searchBackwards ? currentNode.previousSibling : currentNode.nextSibling;
let currentSibling = sibling;
while (currentSibling) {
const result = findTextNodeWithNewline(currentSibling, searchBackwards);
if (result) {
return result;
}
currentSibling = searchBackwards ? currentSibling.previousSibling : currentSibling.nextSibling;
}
currentNode = currentNode.parentNode;
}
return null;
}
/**
* Helper: Get the last text node in a container
*/
function getLastTextNode(container) {
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
let lastNode = null;
let textNode;
while (textNode = walker.nextNode()) {
lastNode = textNode;
}
return lastNode;
}
/**
* Expands a selection range to include complete lines
* If a user selects partial text, this ensures full lines are captured
*/
function expandRangeToFullLines(range, container) {
const newRange = range.cloneRange();
// Expand start to line beginning
if (newRange.startContainer.nodeType === Node.TEXT_NODE) {
const text = newRange.startContainer.textContent;
const lastNewline = text.lastIndexOf('\n', newRange.startOffset - 1);
if (lastNewline !== -1) {
newRange.setStart(newRange.startContainer, lastNewline + 1);
} else {
const lineStart = findLineBoundary(newRange.startContainer, container, true);
if (lineStart) {
newRange.setStart(lineStart.node, lineStart.offset);
} else {
newRange.setStart(container, 0);
}
}
}
// Expand end to line end
if (newRange.endContainer.nodeType === Node.TEXT_NODE) {
const text = newRange.endContainer.textContent;
const nextNewline = text.indexOf('\n', newRange.endOffset);
if (nextNewline !== -1) {
newRange.setEnd(newRange.endContainer, nextNewline);
} else {
const lineEnd = findLineBoundary(newRange.endContainer, container, false);
if (lineEnd) {
newRange.setEnd(lineEnd.node, lineEnd.offset);
} else {
const lastNode = getLastTextNode(container);
newRange.setEnd(
lastNode || container,
lastNode ? lastNode.textContent.length : container.childNodes.length
);
}
}
}
return newRange;
}
/**
* Create a temporary element with the selected content styled for capture
*/
function createCaptureElement(selectedFragment, originalElement) {
const originalStyles = window.getComputedStyle(originalElement);
// Create container with watermark background
const container = document.createElement("div");
container.innerHTML = `
<div style="
position: absolute;
left: -9999px;
top: 0;
padding: 2px;
background-color: transparent;
">
<div style="
background-color: #ffffff;
width: ${originalElement.offsetWidth}px;
border: 1px solid #ccc;
border-radius: 4px;
overflow: hidden;
">
<!-- Watermark background -->
<div style="
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
overflow: hidden;
pointer-events: none;
z-index: 0;
background-image: url(&quot;data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='400' height='200' viewBox='0 0 400 200'><g font-family='Arial' font-size='18' font-weight='700' fill='%23e8e8e8' transform='rotate(-45 200 100)'><text x='0' y='40'>changedetection.io changedetection.io changedetection.io</text><text x='0' y='100'>changedetection.io changedetection.io changedetection.io</text><text x='0' y='160'>changedetection.io changedetection.io changedetection.io</text></g></svg>&quot;);
background-repeat: repeat;
background-size: 400px 200px;
"></div>
<!-- Content -->
<pre id="temp-capture-element" style="
position: relative;
z-index: 1;
white-space: ${originalStyles.whiteSpace};
font-family: ${originalStyles.fontFamily};
font-size: ${originalStyles.fontSize};
line-height: ${originalStyles.lineHeight};
color: ${originalStyles.color};
word-wrap: ${originalStyles.wordWrap};
overflow-wrap: ${originalStyles.overflowWrap};
background-color: transparent;
padding: ${IMAGE_PADDING}px;
border: ${originalStyles.border};
box-sizing: border-box;
margin: 0;
"></pre>
</div>
</div>
`;
const outerWrapper = container.firstElementChild;
const innerWrapper = outerWrapper.querySelector('div');
const tempElement = innerWrapper.querySelector('#temp-capture-element');
tempElement.appendChild(selectedFragment);
// Store innerWrapper for footer appending
outerWrapper._innerWrapper = innerWrapper;
return outerWrapper;
}
/**
* Count lines in a text string or document fragment
*/
function countLines(content) {
if (!content) return 0;
let text = '';
if (typeof content === 'string') {
text = content;
} else if (content.textContent) {
text = content.textContent;
}
// Count newlines + 1 (for the last line)
const lines = text.split('\n').length;
return lines > 0 ? lines : 1;
}
/**
* Create footer with metadata (URL, version info, line count)
*/
function createFooter(selectedLines, totalLines) {
const url = getTargetUrl();
const versionInfo = getVersionInfo();
const lineInfo = (selectedLines && totalLines) ? ` - ${selectedLines} of ${totalLines} lines selected` : '';
const footer = document.createElement("div");
footer.innerHTML = `
<div style="
position: relative;
z-index: 1;
background-color: #1324fd;
color: #fff;
padding: 10px;
margin-top: 10px;
font-size: 12px;
font-family: Arial, sans-serif;
line-height: 1.5;
border-top: 1px solid #ccc;
">
Watched URL: <strong>${url}</strong><br>
${versionInfo}${lineInfo}<br>
Monitored via automated content change detection on public webpages. Data reflects observed text updates, not editorial verification.
</div>
`;
return footer.firstElementChild;
}
/**
* Add EXIF metadata to JPEG image
*/
function addExifMetadata(jpegDataUrl) {
if (typeof piexif === 'undefined') {
return jpegDataUrl;
}
try {
const url = getTargetUrl();
const timestamp = new Date().toISOString();
const exifObj = {
"0th": {
[piexif.ImageIFD.Software]: "changedetection.io",
[piexif.ImageIFD.ImageDescription]: `Diff snapshot from ${url}`,
[piexif.ImageIFD.Copyright]: "Generated by changedetection.io"
},
"Exif": {
[piexif.ExifIFD.DateTimeOriginal]: timestamp,
[piexif.ExifIFD.UserComment]: `URL: ${url} | Captured: ${timestamp} | Source: changedetection.io`
}
};
const exifBytes = piexif.dump(exifObj);
return piexif.insert(exifBytes, jpegDataUrl);
} catch (error) {
console.warn("Failed to add EXIF metadata:", error);
return jpegDataUrl;
}
}
/**
* Convert data URL to Blob for sharing
*/
function dataURLtoBlob(dataURL) {
const parts = dataURL.split(',');
const byteString = atob(parts[1]);
const mimeString = parts[0].split(':')[1].split(';')[0];
const ab = new ArrayBuffer(byteString.length);
const ia = new Uint8Array(ab);
for (let i = 0; i < byteString.length; i++) {
ia[i] = byteString.charCodeAt(i);
}
return new Blob([ab], { type: mimeString });
}
/**
* Download the image
*/
function downloadImage(jpegDataUrl) {
const a = document.createElement("a");
a.href = jpegDataUrl;
a.download = "changedetection-diff-" + Date.now() + ".jpg";
a.click();
}
/**
* Copy image to clipboard
*/
async function copyImageToClipboard(jpegDataUrl) {
try {
const blob = dataURLtoBlob(jpegDataUrl);
await navigator.clipboard.write([
new ClipboardItem({ 'image/jpeg': blob })
]);
alert('Image copied to clipboard!');
} catch (error) {
console.error('Failed to copy image:', error);
alert('Failed to copy image. Your browser may not support this feature.');
}
}
/**
* Share via Web Share API or fallback to platform-specific sharing
*/
async function shareImage(platform, jpegDataUrl) {
const url = getTargetUrl();
const shareText = `Check out this change detected on ${url} via changedetection.io`;
const filename = "changedetection-diff-" + Date.now() + ".jpg";
// Try Web Share API first (works on mobile and some desktop browsers)
if (platform === 'native' && navigator.share) {
try {
const blob = dataURLtoBlob(jpegDataUrl);
const file = new File([blob], filename, { type: 'image/jpeg' });
await navigator.share({
title: 'Change Detection Diff',
text: shareText,
files: [file]
});
return;
} catch (error) {
if (error.name !== 'AbortError') {
console.error('Web Share API failed:', error);
}
return;
}
}
// Platform-specific fallbacks
const encodedText = encodeURIComponent(shareText);
const encodedUrl = encodeURIComponent(url);
let shareUrl;
switch (platform) {
case 'twitter':
shareUrl = `https://twitter.com/intent/tweet?text=${encodedText}`;
break;
case 'facebook':
shareUrl = `https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}&quote=${encodedText}`;
break;
case 'linkedin':
shareUrl = `https://www.linkedin.com/sharing/share-offsite/?url=${encodedUrl}`;
break;
case 'reddit':
shareUrl = `https://reddit.com/submit?url=${encodedUrl}&title=${encodeURIComponent('Change Detection Diff')}`;
break;
case 'email':
shareUrl = `mailto:?subject=${encodeURIComponent('Change Detection Diff')}&body=${encodedText}`;
break;
default:
return;
}
window.open(shareUrl, '_blank', 'width=600,height=400');
}
/**
* Display or download the generated image
*/
function displayImage(jpegDataUrl) {
const win = window.open();
if (win) {
win.document.write(`
<html>
<head>
<title>Diff Screenshot</title>
<style>
body {
margin: 0;
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
background: #f5f5f5;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
img {
max-width: 100%;
display: block;
margin-bottom: 20px;
border: 1px solid #ddd;
border-radius: 4px;
}
.share-section {
padding: 20px 0;
border-top: 2px solid #e0e0e0;
}
.share-section h3 {
margin: 0 0 15px 0;
color: #333;
font-size: 18px;
}
.share-buttons {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.share-btn {
padding: 10px 20px;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 8px;
}
.share-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
.btn-download {
background: #4CAF50;
color: white;
}
.btn-native {
background: #2196F3;
color: white;
}
.btn-twitter {
background: #000000;
color: white;
}
.btn-facebook {
background: #1877F2;
color: white;
}
.btn-linkedin {
background: #0A66C2;
color: white;
}
.btn-reddit {
background: #FF4500;
color: white;
}
.btn-email {
background: #757575;
color: white;
}
</style>
</head>
<body>
<div class="container">
<img src="${jpegDataUrl}" alt="Diff Screenshot" id="diffImage"/>
<div class="share-section">
<h3>Share or Download</h3>
<p style="margin: 0 0 15px 0; padding: 12px; background: #f0f7ff; border-left: 4px solid #2196F3; color: #333; font-size: 14px; line-height: 1.5;">
<strong>💡 Tip:</strong> Right-click the image above and select "Copy Image", then click a share button below and paste it into your post (Ctrl+V or right-click Paste).
</p>
<div class="share-buttons">
<button class="share-btn btn-download" onclick="downloadImage()">
📥 Download Image
</button>
${navigator.share ? '<button class="share-btn btn-native" onclick="shareNative()">📤 Share...</button>' : ''}
<button class="share-btn btn-twitter" onclick="shareToTwitter()">
𝕏 Share to X
</button>
<button class="share-btn btn-facebook" onclick="shareToFacebook()">
Share to Facebook
</button>
<button class="share-btn btn-linkedin" onclick="shareToLinkedIn()">
Share to LinkedIn
</button>
<button class="share-btn btn-reddit" onclick="shareToReddit()">
Share to Reddit
</button>
<button class="share-btn btn-email" onclick="shareViaEmail()">
📧 Share via Email
</button>
</div>
</div>
</div>
<script>
const imageDataUrl = "${jpegDataUrl}";
function dataURLtoBlob(dataURL) {
const parts = dataURL.split(',');
const byteString = atob(parts[1]);
const mimeString = parts[0].split(':')[1].split(';')[0];
const ab = new ArrayBuffer(byteString.length);
const ia = new Uint8Array(ab);
for (let i = 0; i < byteString.length; i++) {
ia[i] = byteString.charCodeAt(i);
}
return new Blob([ab], { type: mimeString });
}
function downloadImage() {
const a = document.createElement("a");
a.href = imageDataUrl;
a.download = "changedetection-diff-" + Date.now() + ".jpg";
a.click();
}
async function shareNative() {
try {
const blob = dataURLtoBlob(imageDataUrl);
const file = new File([blob], "changedetection-diff-" + Date.now() + ".jpg", { type: 'image/jpeg' });
await navigator.share({
title: 'Change Detection Diff',
text: 'Check out this change detected via changedetection.io',
files: [file]
});
} catch (error) {
if (error.name !== 'AbortError') {
console.error('Share failed:', error);
}
}
}
function shareToTwitter() {
const text = encodeURIComponent('Check out this change detected via changedetection.io');
window.open('https://twitter.com/intent/tweet?text=' + text, '_blank', 'width=600,height=400');
}
function shareToFacebook() {
const cdUrl = encodeURIComponent('https://changedetection.io');
window.open('https://www.facebook.com/sharer/sharer.php?u=' + cdUrl, '_blank', 'width=600,height=400');
}
function shareToLinkedIn() {
const cdUrl = encodeURIComponent('https://changedetection.io');
window.open('https://www.linkedin.com/sharing/share-offsite/?url=' + cdUrl, '_blank', 'width=600,height=400');
}
function shareToReddit() {
const cdUrl = encodeURIComponent('https://changedetection.io');
const title = encodeURIComponent('Change Detection Tool');
window.open('https://reddit.com/submit?url=' + cdUrl + '&title=' + title, '_blank', 'width=600,height=400');
}
function shareViaEmail() {
const subject = encodeURIComponent('Change Detection Diff');
const body = encodeURIComponent('Check out this change detected via changedetection.io');
window.location.href = 'mailto:?subject=' + subject + '&body=' + body;
}
</script>
</body>
</html>
`);
} else {
// Fallback: trigger download if popup is blocked
const a = document.createElement("a");
a.href = jpegDataUrl;
a.download = "changedetection-diff-" + Date.now() + ".jpg";
a.click();
}
}
/**
* Update button UI state
*/
function setButtonState(button, isLoading, originalHtml = '') {
if (!button) return;
if (isLoading) {
button.innerHTML = 'Generating...';
button.style.opacity = "0.5";
button.style.pointerEvents = "none";
} else {
button.innerHTML = originalHtml;
button.style.opacity = "1";
button.style.pointerEvents = "auto";
}
}
/**
* Main function: Convert selected diff text to a shareable JPEG image
*
* Features:
* - Expands partial selections to full lines
* - Preserves all diff highlighting and formatting
* - Adds metadata footer with URL and version info
* - Embeds EXIF metadata in the JPEG
* - Opens in new window or downloads if popup blocked
*/
async function diffToJpeg() {
// Validate dependencies
if (typeof html2canvas === 'undefined') {
alert("html2canvas library is not loaded yet. Please wait a moment and try again.");
return;
}
// Validate selection
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
alert("Please select the text/lines you want to capture first by highlighting with your mouse.");
return;
}
const originalRange = selection.getRangeAt(0);
const differenceElement = document.getElementById("difference");
if (!differenceElement || !differenceElement.contains(originalRange.commonAncestorContainer)) {
alert("Please select text within the diff content.");
return;
}
// Setup UI state
const btn = document.getElementById("share-as-image-btn");
const originalBtnHtml = btn ? btn.innerHTML : '';
setButtonState(btn, true);
let tempElement = null;
try {
// Expand selection to full lines and clone content
const expandedRange = expandRangeToFullLines(originalRange, differenceElement);
const selectedFragment = expandedRange.cloneContents();
// Count lines for footer
const selectedLines = countLines(selectedFragment);
const totalLines = countLines(differenceElement);
// Create temporary element with proper styling
tempElement = createCaptureElement(selectedFragment, differenceElement);
// Append footer to innerWrapper (inside the border), not outerWrapper
tempElement._innerWrapper.appendChild(createFooter(selectedLines, totalLines));
// Add to DOM for rendering
document.body.appendChild(tempElement);
// Wait for rendering
await new Promise(resolve => setTimeout(resolve, RENDER_DELAY_MS));
// Capture to canvas
const canvas = await html2canvas(tempElement, {
scale: CANVAS_SCALE,
useCORS: true,
allowTaint: true,
logging: false,
backgroundColor: '#ffffff',
scrollX: 0,
scrollY: 0
});
// Validate canvas
if (canvas.width === 0 || canvas.height === 0) {
throw new Error("Canvas is empty - no content captured");
}
// Convert to JPEG
let jpeg = canvas.toDataURL("image/jpeg", JPEG_QUALITY);
if (jpeg === "data:," || jpeg.length < 100) {
throw new Error("Failed to generate image data");
}
// Add EXIF metadata
jpeg = addExifMetadata(jpeg);
// Display the image
displayImage(jpeg);
// Clear selection
selection.removeAllRanges();
} catch (error) {
console.error("Error generating image:", error);
alert("Failed to generate image: " + error.message);
} finally {
// Cleanup
if (tempElement && tempElement.parentNode) {
tempElement.parentNode.removeChild(tempElement);
}
setButtonState(btn, false, originalBtnHtml);
}
}
+11 -4
View File
@@ -26,12 +26,19 @@ function request_textpreview_update() {
.text(data['after_filter'])
.highlightLines([
{
'color': '#ee0000',
'lines': data['trigger_line_numbers']
'color': 'var(--highlight-trigger-text-bg-color)',
'lines': data['trigger_line_numbers'],
'title': "Triggers a change if this text appears, AND something changed in the document."
},
{
'color': '#757575',
'lines': data['ignore_line_numbers']
'color': 'var(--highlight-ignored-text-bg-color)',
'lines': data['ignore_line_numbers'],
'title': "Ignored for calculating changes, but still shown."
},
{
'color': 'var(--highlight-blocked-text-bg-color)',
'lines': data['blocked_line_numbers'],
'title': "No change-detection will occur because this text exists."
}
])
}).fail(function (error) {
File diff suppressed because one or more lines are too long
+222 -126
View File
@@ -1,151 +1,247 @@
@use "parts/variables";
#diff-form {
background: rgba(0, 0, 0, .05);
padding: 1em;
border-radius: 10px;
margin-bottom: 1em;
color: #fff;
font-size: 0.9rem;
text-align: center;
label.from-to-label {
width: 4rem;
text-decoration: none;
padding: 0.5rem;
&#change-from {
color: #b30000;
background: #fadad7
}
&#change-to {
background: #eaf2c2;
color: #406619;
}
}
#diff-style {
>span {
display: inline-block;
padding: 0.3em;
label {
font-weight: normal;
}
}
}
* {
vertical-align: middle;
}
}
body.difference-page {
section.content {
padding-top: 40px;
}
}
#diff-ui {
background: var(--color-background);
padding: 2em;
margin-left: 1em;
margin-right: 1em;
padding: 1rem;
border-radius: 5px;
@media (min-width: 767px) {
min-width: 50%;
}
// The first tab 'text' diff
#text {
font-size: 11px;
}
table {
table-layout: fixed;
width: 100%;
}
td {
padding: 3px 4px;
border: 1px solid transparent;
vertical-align: top;
font: 1em monospace;
text-align: left;
overflow: clip; // clip overflowing contents to cell boundariess
}
pre {
white-space: break-spaces;
}
h1 {
display: inline;
font-size: 100%;
}
#result {
white-space: pre-wrap;
word-break: break-word;
overflow-wrap: break-word;
}
.source {
position: absolute;
right: 1%;
top: .2em;
}
@-moz-document url-prefix() {
body {
height: 99%;
/* Hide scroll bar in Firefox */
}
}
td#diff-col div {
text-align: justify;
white-space: pre-wrap;
}
}
h1 {
display: inline;
font-size: 100%;
}
.ignored {
background-color: #ccc;
/* border: #0d91fa 1px solid; */
opacity: 0.7;
}
del {
text-decoration: none;
color: #b30000;
background: #fadad7;
}
.triggered {
background-color: #1b98f8;
}
ins {
background: #eaf2c2;
color: #406619;
text-decoration: none;
}
/* ignored and triggered? make it obvious error */
.ignored.triggered {
background-color: #ff0000;
}
#result {
white-space: pre-wrap;
word-break: break-word;
overflow-wrap: break-word;
.tab-pane-inner#screenshot {
text-align: center;
.change {
span {}
img {
max-width: 99%;
}
}
// resets button margin to 0px
.pure-form button.reset-margin {
margin: 0px;
}
.diff-fieldset {
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
}
ul#highlightSnippetActions {
list-style-type: none;
display: flex;
align-items: center;
justify-content: center;
gap: 1.5rem;
flex-wrap: wrap;
padding: 0;
margin: 0;
li {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 0.5rem;
gap: 0.3rem;
button, a {
white-space: nowrap;
}
}
span {
font-size: 0.8rem;
color: var(--color-text-input-description);
}
}
#cell-diff-jump-visualiser {
display: flex;
flex-direction: row;
gap: 1px;
background: var(--color-background);
border-radius: 3px;
overflow-x: hidden;
position: sticky;
top: 0;
z-index: 10;
padding-top: 1rem;
padding-bottom: 1rem;
justify-content: center;
> div {
flex: 1;
min-width: 1px;
max-width: 10px;
height: 10px;
background: var(--color-background-button-cancel);
opacity: 0.3;
border-radius: 1px;
transition: opacity 0.2s;
position: relative;
&.deletion {
background: #b30000; // Red for deletions
opacity: 1;
}
&.insertion {
background: #406619; // Green for insertions
opacity: 1;
}
&.note {
background: #406619; // Orange for changed/notes
opacity: 1;
}
&.mixed {
background: linear-gradient(to right, #b30000 50%, #406619 50%); // Half red, half green
opacity: 1;
}
&.current-position::after {
content: '';
position: absolute;
bottom: -6px;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-bottom: 4px solid var(--color-text);
}
&:hover {
opacity: 0.8;
cursor: pointer;
}
}
}
}
#settings {
background: rgba(0, 0, 0, .05);
padding: 1em;
border-radius: 10px;
margin-bottom: 1em;
color: #fff;
font-size: 80%;
label {
margin-left: 1em;
display: inline-block;
font-weight: normal;
}
del {
padding: 0.5em;
}
ins {
padding: 0.5em;
}
option:checked {
#text-diff-heading-area {
.snapshot-age {
padding: 4px;
margin: 0.5rem 0;
background-color: var(--color-background-snapshot-age);
border-radius: 3px;
font-weight: bold;
margin-bottom: 4px;
&.error {
background-color: var(--color-error-background-snapshot-age);
color: var(--color-error-text-snapshot-age);
}
> * {
padding-right: 1rem;
}
}
[type=radio],[type=checkbox] {
vertical-align: middle;
}
}
.source {
position: absolute;
right: 1%;
top: .2em;
}
@-moz-document url-prefix() {
body {
height: 99%;
/* Hide scroll bar in Firefox */
}
}
td#diff-col div {
text-align: justify;
white-space: pre-wrap;
}
.ignored {
background-color: #ccc;
/* border: #0d91fa 1px solid; */
opacity: 0.7;
}
.triggered {
background-color: #1b98f8;
}
/* ignored and triggered? make it obvious error */
.ignored.triggered {
background-color: #ff0000;
}
.tab-pane-inner#screenshot {
text-align: center;
img {
max-width: 99%;
}
}
#highlightSnippet {
background: var(--color-background);
padding: 1em;
border-radius: 5px;
background: var(--color-background);
box-shadow: 1px 1px 4px var(--color-shadow-jump);
}
// resets button margin to 0px
.pure-form button.reset-margin {
margin: 0px;
}
.diff-fieldset {
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
}
@@ -102,6 +102,10 @@
--color-watch-table-error: var(--color-dark-red);
--color-watch-table-row-text: var(--color-grey-100);
--highlight-trigger-text-bg-color: #1b98f8;
--highlight-ignored-text-bg-color: var(--color-grey-700);
--highlight-blocked-text-bg-color: rgb(202, 60, 60);
}
html[data-darkmode="true"] {
@@ -982,19 +982,6 @@ ul {
supported by Chrome, Edge, Opera and Firefox */
}
.snapshot-age {
padding: 4px;
margin: 0.5rem 0;
background-color: var(--color-background-snapshot-age);
border-radius: 3px;
font-weight: bold;
margin-bottom: 4px;
&.error {
background-color: var(--color-error-background-snapshot-age);
color: var(--color-error-text-snapshot-age);
}
}
#checkbox-operations {
background: var(--color-background-checkbox-operations);
@@ -1127,3 +1114,38 @@ ul {
color: #fff;
opacity: 0.8;
}
#bottom-horizontal-offscreen {
position: fixed;
bottom: 0;
left: 0;
right: 0;
width: 100%;
min-height: 50px;
max-height: 50vh; // Don't take more than 50% of viewport height
background: #ffffffb8;
border-top: 1px solid var(--color-border-table-cell);
padding: 10px;
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.2);
z-index: 100;
overflow-y: auto; // Allow scrolling if content exceeds max-height
// Smooth transition when shown/hidden
transition: opacity 0.3s ease-in-out;
// When JavaScript removes display:none, ensure it scrolls into view
scroll-margin-bottom: 10px;
// Center contents horizontally
display: flex;
justify-content: center;
align-items: center;
}
ul#highlightSnippetActions {
list-style: none;
li {
display: inline-block;
}
}
File diff suppressed because one or more lines are too long
+8 -4
View File
@@ -186,10 +186,6 @@
<br>
{% endmacro %}
{% macro only_playwright_type_watches_warning() %}
<p><strong>Sorry, this functionality only works with Playwright/Chrome enabled watches.<br>You need to <a href="#request">Set the fetch method to Playwright/Chrome mode and resave</a> and have the SockpuppetBrowser/Playwright or Selenium enabled.</strong></p><br>
{% endmacro %}
{% macro render_time_schedule_form(form, available_timezones, timezone_default_config) %}
<style>
.day-schedule *, .day-schedule select {
@@ -282,4 +278,12 @@
<br>
{% endif %}
{% endmacro %}
{% macro highlight_trigger_ignored_explainer() %}
<p>
<span title="Triggers a change if this text appears, AND something changed in the document." style="background-color: var(--highlight-trigger-text-bg-color); color: #fff; padding: 4px; border-radius: 2px; margin-right: 4px;">Triggered text</span>
<span title="Ignored for calculating changes, but still shown." style="background-color: var(--highlight-ignored-text-bg-color); color: #fff; padding: 4px; border-radius: 2px; margin-right: 4px;">Ignored text</span>
<span title="No change-detection will occur because this text exists." style="background-color: var(--highlight-blocked-text-bg-color); color: #fff; padding: 4px; border-radius: 2px; margin-right: 4px;">Blocked text</span>
</p>
{% endmacro %}
+7 -4
View File
@@ -48,7 +48,7 @@
<body class="{{extra_classes}}">
<div class="header">
<div class="pure-menu-fixed" style="width: 100%;">
<div {% if pure_menu_fixed != False %}class="pure-menu-fixed"{% endif %} style="width: 100%;">
<div class="home-menu pure-menu pure-menu-horizontal" id="nav-menu">
{% if has_password and not current_user.is_authenticated %}
@@ -157,8 +157,6 @@
{% endif %}
{% if left_sticky %}
<div class="sticky-tab" id="left-sticky">
<a href="{{url_for('ui.ui_views.preview_page', uuid=uuid)}}">Show current snapshot</a><br>
Visualise <strong>triggers</strong> and <strong>ignored text</strong>
</div>
{% endif %}
{% if right_sticky %}
@@ -241,8 +239,13 @@
</section>
<script src="{{url_for('static_content', group='js', filename='toggle-theme.js')}}" defer></script>
<div id="checking-now-fixed-tab" style="display: none;"><span class="spinner"></span><span>&nbsp;Checking now</span></div>
<div id="checking-now-fixed-tab" style="display: none;"><span class="spinner"></span><span class="status-text">&nbsp;Checking now</span></div>
<div id="realtime-conn-error" style="display:none">Real-time updates offline</div>
{% if bottom_horizontal_offscreen_contents %}
<div id="bottom-horizontal-offscreen" style="display:none">
{{ bottom_horizontal_offscreen_contents|safe }}
</div>
{% endif %}
</body>
</html>
-167
View File
@@ -1,167 +0,0 @@
{% extends 'base.html' %}
{% from '_helpers.html' import render_field, render_checkbox_field, render_button %}
{% block content %}
<script>
const screenshot_url="{{url_for('static_content', group='screenshot', filename=uuid)}}";
{% if last_error_screenshot %}
const error_screenshot_url="{{url_for('static_content', group='screenshot', filename=uuid, error_screenshot=1) }}";
{% endif %}
const highlight_submit_ignore_url="{{url_for('ui.ui_edit.highlight_submit_ignore_url', uuid=uuid)}}";
</script>
<script src="{{url_for('static_content', group='js', filename='diff-overview.js')}}" defer></script>
<div id="settings">
<form class="pure-form " action="" method="GET" id="diff-form">
<fieldset class="diff-fieldset">
{% if versions|length >= 1 %}
<strong>Compare</strong>
<del class="change"><span>from</span></del>
<select id="diff-version" name="from_version" class="needs-localtime">
{% for version in versions|reverse %}
<option value="{{ version }}" {% if version== from_version %} selected="" {% endif %}>
{{ version }}
</option>
{% endfor %}
</select>
<ins class="change"><span>to</span></ins>
<select id="current-version" name="to_version" class="needs-localtime">
{% for version in versions|reverse %}
<option value="{{ version }}" {% if version== to_version %} selected="" {% endif %}>
{{ version }}
</option>
{% endfor %}
</select>
<button type="submit" class="pure-button pure-button-primary reset-margin">Go</button>
{% endif %}
</fieldset>
<fieldset>
<strong>Style</strong>
<label for="diffWords" class="pure-checkbox">
<input type="radio" name="diff_type" id="diffWords" value="diffWords"> Words</label>
<label for="diffLines" class="pure-checkbox">
<input type="radio" name="diff_type" id="diffLines" value="diffLines" checked=""> Lines</label>
<label for="diffChars" class="pure-checkbox">
<input type="radio" name="diff_type" id="diffChars" value="diffChars"> Chars</label>
<!-- @todo - when mimetype is JSON, select this by default? -->
<label for="diffJson" class="pure-checkbox">
<input type="radio" name="diff_type" id="diffJson" value="diffJson"> JSON</label>
<span>
<!-- https://github.com/kpdecker/jsdiff/issues/389 ? -->
<label for="ignoreWhitespace" class="pure-checkbox" id="label-diff-ignorewhitespace">
<input type="checkbox" id="ignoreWhitespace" name="ignoreWhitespace"> Ignore Whitespace</label>
</span>
</fieldset>
</form>
</div>
<div id="diff-jump">
<a id="jump-next-diff" title="Jump to next difference">Jump</a>
</div>
<script src="{{url_for('static_content', group='js', filename='tabs.js')}}" defer></script>
<div class="tabs">
<ul>
{% if last_error_text %}<li class="tab" id="error-text-tab"><a href="#error-text">Error Text</a></li> {% endif %}
{% if last_error_screenshot %}<li class="tab" id="error-screenshot-tab"><a href="#error-screenshot">Error Screenshot</a></li> {% endif %}
<li class="tab" id=""><a href="#text">Text</a></li>
<li class="tab" id="screenshot-tab"><a href="#screenshot">Screenshot</a></li>
<li class="tab" id="extract-tab"><a href="#extract">Extract Data</a></li>
</ul>
</div>
<div id="diff-ui">
<div class="tab-pane-inner" id="error-text">
<div class="snapshot-age error">{{watch_a.error_text_ctime|format_seconds_ago}} seconds ago</div>
<pre>
{{ last_error_text }}
</pre>
</div>
<div class="tab-pane-inner" id="error-screenshot">
<div class="snapshot-age error">{{watch_a.snapshot_error_screenshot_ctime|format_seconds_ago}} seconds ago</div>
<img id="error-screenshot-img" style="max-width: 80%" alt="Current error-ing screenshot from most recent request" >
</div>
<div class="tab-pane-inner" id="text">
{% if password_enabled_and_share_is_off %}
<div class="tip">Pro-tip: You can enable <strong>"share access when password is enabled"</strong> from settings</div>
{% endif %}
<div class="snapshot-age">{{watch_a.snapshot_text_ctime|format_timestamp_timeago}}</div>
<table>
<tbody>
<tr>
<!-- just proof of concept copied straight from github.com/kpdecker/jsdiff -->
<td id="a" style="display: none;">{{from_version_file_contents}}</td>
<td id="b" style="display: none;">{{to_version_file_contents}}</td>
<td id="diff-col">
<span id="result" class="highlightable-filter"></span>
</td>
</tr>
</tbody>
</table>
Diff algorithm from the amazing <a href="https://github.com/kpdecker/jsdiff">github.com/kpdecker/jsdiff</a>
</div>
<div class="tab-pane-inner" id="screenshot">
<div class="tip">
For now, Differences are performed on text, not graphically, only the latest screenshot is available.
</div>
{% if is_html_webdriver %}
{% if screenshot %}
<div class="snapshot-age">{{watch_a.snapshot_screenshot_ctime|format_timestamp_timeago}}</div>
<img style="max-width: 80%" id="screenshot-img" alt="Current screenshot from most recent request" >
{% else %}
No screenshot available just yet! Try rechecking the page.
{% endif %}
{% else %}
<strong>Screenshot requires Playwright/WebDriver enabled</strong>
{% endif %}
</div>
<div class="tab-pane-inner" id="extract">
<form id="extract-data-form" class="pure-form pure-form-stacked edit-form"
action="{{ url_for('ui.ui_views.diff_history_page', uuid=uuid) }}#extract"
method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<p>This tool will extract text data from all of the watch history.</p>
<div class="pure-control-group">
{{ render_field(extract_form.extract_regex) }}
<span class="pure-form-message-inline">
A <strong>RegEx</strong> is a pattern that identifies exactly which part inside of the text that you want to extract.<br>
<p>
For example, to extract only the numbers from text &dash;<br>
<strong>Raw text</strong>: <code>Temperature <span style="color: red">5.5</span>°C in Sydney</code><br>
<strong>RegEx to extract:</strong> <code>Temperature <span style="color: red">([0-9\.]+)</span></code><br>
</p>
<p>
<a href="https://RegExr.com/">Be sure to test your RegEx here.</a>
</p>
<p>
Each RegEx group bracket <code>()</code> will be in its own column, the first column value is always the date.
</p>
</span>
</div>
<div class="pure-control-group">
{{ render_button(extract_form.extract_submit_button) }}
</div>
</form>
</div>
</div>
<script>
const newest_version_timestamp = {{newest_version_timestamp}};
</script>
<script src="{{url_for('static_content', group='js', filename='diff.min.js')}}"></script>
<script src="{{url_for('static_content', group='js', filename='diff-render.js')}}"></script>
{% endblock %}
@@ -66,7 +66,7 @@ def do_test(client, live_server, make_test_use_extra_browser=False):
wait_for_all_checks(client)
res = client.get(
url_for("ui.ui_views.preview_page", uuid="first"),
url_for("ui.ui_preview.preview_page", uuid="first"),
follow_redirects=True
)
assert b'cool it works' in res.data
@@ -36,7 +36,7 @@ def test_fetch_webdriver_content(client, live_server, measure_memory_usage, data
wait_for_all_checks(client)
res = client.get(
url_for("ui.ui_views.preview_page", uuid="first"),
url_for("ui.ui_preview.preview_page", uuid="first"),
follow_redirects=True
)
logging.getLogger().info("Looking for correct fetched HTML (text) from server")
@@ -42,7 +42,7 @@ def test_execute_custom_js(client, live_server, measure_memory_usage, datastore_
# Check HTML conversion detected and workd
res = client.get(
url_for("ui.ui_views.preview_page", uuid=uuid),
url_for("ui.ui_preview.preview_page", uuid=uuid),
follow_redirects=True
)
assert b"This text should be removed" not in res.data
@@ -40,7 +40,7 @@ def test_select_custom(client, live_server, measure_memory_usage, datastore_path
assert b'Proxy Authentication Required' not in res.data
res = client.get(
url_for("ui.ui_views.preview_page", uuid="first"),
url_for("ui.ui_preview.preview_page", uuid="first"),
follow_redirects=True
)
# We should see something via proxy
@@ -74,7 +74,7 @@ def test_socks5(client, live_server, measure_memory_usage, datastore_path):
wait_for_all_checks(client)
res = client.get(
url_for("ui.ui_views.preview_page", uuid="first"),
url_for("ui.ui_preview.preview_page", uuid="first"),
follow_redirects=True
)
@@ -61,7 +61,7 @@ def test_socks5_from_proxiesjson_file(client, live_server, measure_memory_usage,
wait_for_all_checks(client)
res = client.get(
url_for("ui.ui_views.preview_page", uuid="first"),
url_for("ui.ui_preview.preview_page", uuid="first"),
follow_redirects=True
)
@@ -8,7 +8,7 @@ from email.policy import default
# Accept a SMTP message and offer a way to retrieve the last message via HTTP
last_received_message = b"Nothing received yet."
last_received_message = b"SMTP Test Server - Nothing received yet."
active_smtp_connections = 0
smtp_lock = threading.Lock()
@@ -713,7 +713,6 @@ def test_check_html_document_plaintext_notification(client, live_server, measure
assert not msg.is_multipart()
assert msg.get_content_type() == 'text/plain'
body = msg.get_content()
assert '<tag>' in body # Should have got converted from original HTML to plaintext
assert '(changed) some stuff\r\n' in body
assert 'PLACEMARKER' not in body
@@ -49,7 +49,7 @@ def test_check_access_control(app, client, live_server, measure_memory_usage, da
assert b"Login" in res.data
# The diff page should return something valid when logged out
res = c.get(url_for("ui.ui_views.diff_history_page", uuid="first"))
res = c.get(url_for("ui.ui_diff.diff_history_page", uuid="first"))
assert b'Random content' in res.data
# access to assets should work (check_authentication)
@@ -186,5 +186,5 @@ def test_check_access_control(app, client, live_server, measure_memory_usage, da
assert res.status_code == 403
# The diff page should return something valid when logged out
res = c.get(url_for("ui.ui_views.diff_history_page", uuid="first"))
res = c.get(url_for("ui.ui_diff.diff_history_page", uuid="first"))
assert b'Random content' not in res.data
+20 -1
View File
@@ -138,6 +138,7 @@ def test_api_simple(client, live_server, measure_memory_usage, datastore_path):
url_for("watchhistory", uuid=watch_uuid),
headers={'x-api-key': api_key},
)
watch_history = res.json
assert len(res.json) == 2, "Should have two history entries (the original and the changed)"
# Fetch a snapshot by timestamp, check the right one was found
@@ -163,6 +164,20 @@ def test_api_simple(client, live_server, measure_memory_usage, datastore_path):
assert b'which has this one new line' in res.data
assert b'<div id' in res.data
# Fetch the difference between two versions
res = client.get(
url_for("watchhistorydiff", uuid=watch_uuid, from_timestamp='previous', to_timestamp='latest'),
headers={'x-api-key': api_key},
)
assert b'(changed) Which is across' in res.data
res = client.get(
url_for("watchhistorydiff", uuid=watch_uuid, from_timestamp='previous', to_timestamp='latest')+'?format=htmlcolor',
headers={'x-api-key': api_key},
)
assert b'aria-label="Changed text" title="Changed text">Which is across multiple lines' in res.data
# Fetch the whole watch
res = client.get(
url_for("watch", uuid=watch_uuid),
@@ -174,7 +189,7 @@ def test_api_simple(client, live_server, measure_memory_usage, datastore_path):
assert watch.get('viewed') == False
# Loading the most recent snapshot should force viewed to become true
client.get(url_for("ui.ui_views.diff_history_page", uuid="first"), follow_redirects=True)
client.get(url_for("ui.ui_diff.diff_history_page", uuid="first"), follow_redirects=True)
time.sleep(3)
# Fetch the whole watch again, viewed should be true
@@ -231,6 +246,10 @@ def test_api_simple(client, live_server, measure_memory_usage, datastore_path):
assert res.json.get('notification_muted') == 0
######################################################
# Finally delete the watch
res = client.delete(
url_for("watch", uuid=watch_uuid),
+1 -1
View File
@@ -26,7 +26,7 @@ def test_basic_auth(client, live_server, measure_memory_usage, datastore_path):
wait_for_all_checks(client)
res = client.get(
url_for("ui.ui_views.preview_page", uuid="first"),
url_for("ui.ui_preview.preview_page", uuid="first"),
follow_redirects=True
)
@@ -97,7 +97,7 @@ def test_check_ldjson_price_autodetect(client, live_server, measure_memory_usage
# Accept it
uuid = next(iter(live_server.app.config['DATASTORE'].data['watching']))
#time.sleep(1)
time.sleep(3)
client.get(url_for('price_data_follower.accept', uuid=uuid, follow_redirects=True))
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
wait_for_all_checks(client)
+12 -8
View File
@@ -17,12 +17,12 @@ def test_inscriptus():
assert stripped_text_from_html == 'test!\nok man'
def test_check_basic_change_detection_functionality(client, live_server, measure_memory_usage, datastore_path):
set_original_response(datastore_path=datastore_path)
uuid = client.application.config.get('DATASTORE').add_watch(url=url_for('test_endpoint', _external=True))
# Do this a few times.. ensures we dont accidently set the status
for n in range(3):
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
@@ -45,7 +45,7 @@ def test_check_basic_change_detection_functionality(client, live_server, measure
# Check HTML conversion detected and workd
res = client.get(
url_for("ui.ui_views.preview_page", uuid="first"),
url_for("ui.ui_preview.preview_page", uuid="first"),
follow_redirects=True
)
# Check this class does not appear (that we didnt see the actual source)
@@ -82,12 +82,16 @@ def test_check_basic_change_detection_functionality(client, live_server, measure
#
# Following the 'diff' link, it should no longer display as 'has-unread-changes' even after we recheck it a few times
res = client.get(url_for("ui.ui_views.diff_history_page", uuid=uuid))
res = client.get(url_for("ui.ui_diff.diff_history_page", uuid=uuid))
assert b'selected=""' in res.data, "Confirm diff history page loaded"
assert b'Which is across multiple lines' in res.data
# The linefeed should have been added ( @BR@ was replaced with a linefeed because this is htmlcolor kinda display )
assert b'Which is across multiple lines</span>\n' in res.data
# Check the [preview] pulls the right one
res = client.get(
url_for("ui.ui_views.preview_page", uuid="first"),
url_for("ui.ui_preview.preview_page", uuid="first"),
follow_redirects=True
)
assert b'which has this one new line' in res.data
@@ -269,7 +273,7 @@ got it\r\n
### check the front end
res = client.get(
url_for("ui.ui_views.preview_page", uuid="first"),
url_for("ui.ui_preview.preview_page", uuid="first"),
follow_redirects=True
)
assert b"some random text that should be split by line\n" in res.data
@@ -329,7 +333,7 @@ got it\r\n
### check the front end
res = client.get(
url_for("ui.ui_views.preview_page", uuid="first"),
url_for("ui.ui_preview.preview_page", uuid="first"),
follow_redirects=True
)
@@ -374,7 +378,7 @@ def test_plaintext_even_if_xml_content(client, live_server, measure_memory_usage
wait_for_all_checks(client)
res = client.get(
url_for("ui.ui_views.preview_page", uuid="first"),
url_for("ui.ui_preview.preview_page", uuid="first"),
follow_redirects=True
)
@@ -401,7 +405,7 @@ def test_plaintext_even_if_xml_content_and_can_apply_filters(client, live_server
wait_for_all_checks(client)
res = client.get(
url_for("ui.ui_views.preview_page", uuid="first"),
url_for("ui.ui_preview.preview_page", uuid="first"),
follow_redirects=True
)
@@ -7,6 +7,7 @@ from changedetectionio import html_tools
import os
def set_original_ignore_response(datastore_path):
test_return_data = """<html>
<body>
Some initial text<br>
@@ -63,24 +64,23 @@ def set_modified_response_minus_block_text(datastore_path):
def test_check_block_changedetection_text_NOT_present(client, live_server, measure_memory_usage, datastore_path):
# live_server_setup(live_server) # Setup on conftest per function
# Use a mix of case in ZzZ to prove it works case-insensitive.
ignore_text = "out of stoCk\r\nfoobar"
set_original_ignore_response(datastore_path=datastore_path)
# Add our URL to the import page
test_url = url_for('test_endpoint', _external=True)
uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
uuid = next(iter(live_server.app.config['DATASTORE'].data['watching']))
# Give the thread time to pick it up
wait_for_all_checks(client)
# Goto the edit page, add our ignore text
# Add our URL to the import page
res = client.post(
url_for("ui.ui_edit.edit_page", uuid="first"),
url_for("ui.ui_edit.edit_page", uuid=uuid),
data={"text_should_not_be_present": ignore_text,
"url": test_url,
'fetch_backend': "html_requests",
@@ -94,7 +94,7 @@ def test_check_block_changedetection_text_NOT_present(client, live_server, measu
wait_for_all_checks(client)
# Check it saved
res = client.get(
url_for("ui.ui_edit.edit_page", uuid="first"),
url_for("ui.ui_edit.edit_page", uuid=uuid),
)
assert bytes(ignore_text.encode('utf-8')) in res.data
@@ -130,7 +130,6 @@ def test_check_block_changedetection_text_NOT_present(client, live_server, measu
res = client.get(url_for("watchlist.index"))
assert b'has-unread-changes' not in res.data
# Now we set a change where the text is gone AND its different content, it should now trigger
set_modified_response_minus_block_text(datastore_path=datastore_path)
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
@@ -139,7 +138,16 @@ def test_check_block_changedetection_text_NOT_present(client, live_server, measu
assert b'has-unread-changes' in res.data
# Clearing all history then viewing it should show us what is blocked
set_modified_original_ignore_response(datastore_path=datastore_path)
client.get(url_for("ui.clear_watch_history", uuid=uuid))
wait_for_all_checks(client)
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
wait_for_all_checks(client)
res = client.get(
url_for("ui.ui_preview.preview_page", uuid=uuid)
)
assert b'blocked_line_numbers = [10]' in res.data
delete_all_watches(client)
+1 -1
View File
@@ -291,7 +291,7 @@ def test_lev_conditions_plugin(client, live_server, measure_memory_usage, datast
# Check the content saved initially, even tho a condition was set - this is the first snapshot so shouldnt be affected by conditions
res = client.get(
url_for("ui.ui_views.preview_page", uuid=uuid),
url_for("ui.ui_preview.preview_page", uuid=uuid),
follow_redirects=True
)
assert b'Which is across multiple lines' in res.data
+1 -1
View File
@@ -157,7 +157,7 @@ def test_check_multiple_filters(client, live_server, measure_memory_usage, datas
wait_for_all_checks(client)
res = client.get(
url_for("ui.ui_views.preview_page", uuid="first"),
url_for("ui.ui_preview.preview_page", uuid="first"),
follow_redirects=True
)
@@ -192,7 +192,7 @@ def test_element_removal_full(client, live_server, measure_memory_usage, datasto
wait_for_all_checks(client)
# so that we set the state to 'has-unread-changes' after all the edits
client.get(url_for("ui.ui_views.diff_history_page", uuid="first"))
client.get(url_for("ui.ui_diff.diff_history_page", uuid="first"))
# Make a change to header/footer/nav
set_modified_response(datastore_path=datastore_path)
@@ -239,7 +239,7 @@ body > table > tr:nth-child(3) > td:nth-child(3)""",
wait_for_all_checks(client)
res = client.get(
url_for("ui.ui_views.preview_page", uuid="first"),
url_for("ui.ui_preview.preview_page", uuid="first"),
follow_redirects=True
)
+2 -2
View File
@@ -41,7 +41,7 @@ def test_check_encoding_detection(client, live_server, measure_memory_usage, dat
assert live_server.app.config['DATASTORE'].data['watching'][uuid]['content-type'] == "text/html"
res = client.get(
url_for("ui.ui_views.preview_page", uuid="first"),
url_for("ui.ui_preview.preview_page", uuid="first"),
follow_redirects=True
)
@@ -63,7 +63,7 @@ def test_check_encoding_detection_missing_content_type_header(client, live_serve
wait_for_all_checks(client)
res = client.get(
url_for("ui.ui_views.preview_page", uuid="first"),
url_for("ui.ui_preview.preview_page", uuid="first"),
follow_redirects=True
)
@@ -34,7 +34,7 @@ def _runner_test_http_errors(client, live_server, http_code, expected_text, data
# Error viewing tabs should appear
res = client.get(
url_for("ui.ui_views.preview_page", uuid="first"),
url_for("ui.ui_preview.preview_page", uuid="first"),
follow_redirects=True
)
+1 -1
View File
@@ -41,7 +41,7 @@ def test_check_extract_text_from_diff(client, live_server, measure_memory_usage,
wait_for_all_checks(client)
res = client.post(
url_for("ui.ui_views.diff_history_page", uuid="first"),
url_for("ui.ui_diff.diff_history_page_extract_POST", uuid="first"),
data={"extract_regex": "Now it's ([0-9\.]+)",
"extract_submit_button": "Extract as CSV"},
follow_redirects=False
@@ -107,7 +107,7 @@ def test_check_filter_multiline(client, live_server, measure_memory_usage, datas
assert b'not at the start of the expression' not in res.data
res = client.get(
url_for("ui.ui_views.preview_page", uuid="first"),
url_for("ui.ui_preview.preview_page", uuid="first"),
follow_redirects=True
)
# Plaintext that doesnt look like a regex should match also
@@ -174,7 +174,7 @@ def test_check_filter_and_regex_extract(client, live_server, measure_memory_usag
# Check HTML conversion detected and workd
res = client.get(
url_for("ui.ui_views.preview_page", uuid="first"),
url_for("ui.ui_preview.preview_page", uuid="first"),
follow_redirects=True
)
+2 -2
View File
@@ -94,7 +94,7 @@ def test_setup_group_tag(client, live_server, measure_memory_usage, datastore_pa
assert b'Warning, no filters were found' not in res.data
res = client.get(
url_for("ui.ui_views.preview_page", uuid="first"),
url_for("ui.ui_preview.preview_page", uuid="first"),
follow_redirects=True
)
assert b'Should be only this' in res.data
@@ -419,7 +419,7 @@ def test_order_of_filters_tag_filter_and_watch_filter(client, live_server, measu
wait_for_all_checks(client)
res = client.get(
url_for("ui.ui_views.preview_page", uuid="first"),
url_for("ui.ui_preview.preview_page", uuid="first"),
follow_redirects=True
)
@@ -100,7 +100,7 @@ def test_check_text_history_view(client, live_server, measure_memory_usage, data
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
wait_for_all_checks(client)
res = client.get(url_for("ui.ui_views.diff_history_page", uuid="first"))
res = client.get(url_for("ui.ui_diff.diff_history_page", uuid="first"))
assert b'test-one' in res.data
assert b'test-two' in res.data
@@ -112,7 +112,7 @@ def test_check_text_history_view(client, live_server, measure_memory_usage, data
wait_for_all_checks(client)
# It should remember the last viewed time, so the first difference is not shown
res = client.get(url_for("ui.ui_views.diff_history_page", uuid="first"))
res = client.get(url_for("ui.ui_diff.diff_history_page", uuid="first"))
assert b'test-three' in res.data
assert b'test-two' in res.data
assert b'test-one' not in res.data
+2 -2
View File
@@ -49,7 +49,7 @@ def test_ignore(client, live_server, measure_memory_usage, datastore_path):
assert b'href' in res.data
# It should not be in the preview anymore
res = client.get(url_for("ui.ui_views.preview_page", uuid=uuid))
res = client.get(url_for("ui.ui_preview.preview_page", uuid=uuid))
assert b'<div class="ignored">oh yeah 456' not in res.data
# Should be in base.html
@@ -84,6 +84,6 @@ def test_strip_ignore_lines(client, live_server, measure_memory_usage, datastore
uuid = next(iter(live_server.app.config['DATASTORE'].data['watching']))
# It should not be in the preview anymore
res = client.get(url_for("ui.ui_views.preview_page", uuid=uuid))
res = client.get(url_for("ui.ui_preview.preview_page", uuid=uuid))
assert b'<div class="ignored">' not in res.data
assert b'Which is across multiple' not in res.data
+8 -1
View File
@@ -130,6 +130,11 @@ def test_check_ignore_text_functionality(client, live_server, measure_memory_usa
assert b'has-unread-changes' not in res.data
assert b'/test-endpoint' in res.data
res = client.get(url_for("ui.ui_preview.preview_page", uuid="first"))
# nothing ignored because none of the text matched
assert b'ignored_line_numbers = []' in res.data
# Make a change
set_modified_ignore_response(datastore_path=datastore_path)
@@ -153,12 +158,14 @@ def test_check_ignore_text_functionality(client, live_server, measure_memory_usa
res = client.get(url_for("watchlist.index"))
assert b'has-unread-changes' in res.data
res = client.get(url_for("ui.ui_views.preview_page", uuid="first"))
res = client.get(url_for("ui.ui_preview.preview_page", uuid="first"))
# SHOULD BE be in the preview, it was added in set_modified_original_ignore_response()
# and we have "new ignore stuff" in ignore_text
# it is only ignored, it is not removed (it will be highlighted too)
assert b'new ignore stuff' in res.data
# Data for the highlighting is present (this is done in JS for now)
assert b'ignored_line_numbers = [8]' in res.data
delete_all_watches(client)
@@ -82,7 +82,7 @@ def test_render_anchor_tag_content_true(client, live_server, measure_memory_usag
wait_for_all_checks(client)
# We should not see the rendered anchor tag
res = client.get(url_for("ui.ui_views.preview_page", uuid="first"))
res = client.get(url_for("ui.ui_preview.preview_page", uuid="first"))
assert '(/modified_link)' not in res.data.decode()
# Goto the settings page, ENABLE render anchor tag
@@ -106,7 +106,7 @@ def test_render_anchor_tag_content_true(client, live_server, measure_memory_usag
# check that the anchor tag content is rendered
res = client.get(url_for("ui.ui_views.preview_page", uuid="first"))
res = client.get(url_for("ui.ui_preview.preview_page", uuid="first"))
assert '(/modified_link)' in res.data.decode()
# since the link has changed, and we chose to render anchor tag content,
+2 -2
View File
@@ -30,7 +30,7 @@ def test_jinja2_in_url_query(client, live_server, measure_memory_usage, datastor
# It should report nothing found (no new 'has-unread-changes' class)
res = client.get(
url_for("ui.ui_views.preview_page", uuid="first"),
url_for("ui.ui_preview.preview_page", uuid="first"),
follow_redirects=True
)
assert b'date=2' in res.data
@@ -56,7 +56,7 @@ def test_jinja2_time_offset_in_url_query(client, live_server, measure_memory_usa
# Verify the URL was processed correctly (should not have errors)
res = client.get(
url_for("ui.ui_views.preview_page", uuid="first"),
url_for("ui.ui_preview.preview_page", uuid="first"),
follow_redirects=True
)
# Should have a valid timestamp in the response
@@ -210,7 +210,7 @@ def test_check_json_without_filter(client, live_server, measure_memory_usage, da
wait_for_all_checks(client)
res = client.get(
url_for("ui.ui_views.preview_page", uuid="first"),
url_for("ui.ui_preview.preview_page", uuid="first"),
follow_redirects=True
)
@@ -253,7 +253,7 @@ def check_json_filter(json_filter, client, live_server, datastore_path):
assert b'has-unread-changes' in res.data
# Should not see this, because its not in the JSONPath we entered
res = client.get(url_for("ui.ui_views.diff_history_page", uuid=uuid))
res = client.get(url_for("ui.ui_diff.diff_history_page", uuid=uuid))
# But the change should be there, tho its hard to test the change was detected because it will show old and new versions
# And #462 - check we see the proper utf-8 string there
@@ -289,7 +289,7 @@ def check_json_filter_bool_val(json_filter, client, live_server, datastore_path)
# Give the thread time to pick it up
wait_for_all_checks(client)
res = client.get(url_for("ui.ui_views.diff_history_page", uuid="first"))
res = client.get(url_for("ui.ui_diff.diff_history_page", uuid="first"))
# But the change should be there, tho its hard to test the change was detected because it will show old and new versions
assert b'false' in res.data
@@ -361,7 +361,7 @@ def check_json_ext_filter(json_filter, client, live_server, datastore_path):
res = client.get(url_for("watchlist.index"))
assert b'has-unread-changes' in res.data
res = client.get(url_for("ui.ui_views.preview_page", uuid="first"))
res = client.get(url_for("ui.ui_preview.preview_page", uuid="first"))
# We should never see 'ForSale' because we are selecting on 'Sold' in the rule,
# But we should know it triggered ('has-unread-changes' assert above)
@@ -371,7 +371,7 @@ def check_json_ext_filter(json_filter, client, live_server, datastore_path):
# And the difference should have both?
res = client.get(url_for("ui.ui_views.diff_history_page", uuid="first"))
res = client.get(url_for("ui.ui_diff.diff_history_page", uuid="first"))
assert b'ForSale' in res.data
assert b'Sold' in res.data
@@ -432,7 +432,7 @@ def test_correct_header_detect(client, live_server, measure_memory_usage, datast
assert b'No parsable JSON found in this document' not in res.data
res = client.get(
url_for("ui.ui_views.preview_page", uuid="first"),
url_for("ui.ui_preview.preview_page", uuid="first"),
follow_redirects=True
)
+2 -3
View File
@@ -571,14 +571,13 @@ def _test_color_notifications(client, notification_body_token, datastore_path):
assert b'Queued 1 watch for rechecking.' in res.data
wait_for_all_checks(client)
time.sleep(3)
time.sleep(2)
with open(os.path.join(datastore_path, "notification.txt"), 'r') as f:
x = f.read()
s = f'<span style="{HTML_CHANGED_STYLE}" role="note" aria-label="Changed text" title="Changed text">Which is across multiple lines'
s = f'<span style="{HTML_CHANGED_STYLE}" role="note" aria-label="Changed text" title="Changed text">Which is across multiple lines</span><br>'
assert s in x
client.get(
url_for("ui.form_delete", uuid="all"),
follow_redirects=True
+1 -1
View File
@@ -33,7 +33,7 @@ def test_obfuscations(client, live_server, measure_memory_usage, datastore_path)
# Check HTML conversion detected and workd
res = client.get(
url_for("ui.ui_views.preview_page", uuid="first"),
url_for("ui.ui_preview.preview_page", uuid="first"),
follow_redirects=True
)
+2 -2
View File
@@ -56,7 +56,7 @@ def test_fetch_pdf(client, live_server, measure_memory_usage, datastore_path):
# The original checksum should be not be here anymore (cdio adds it to the bottom of the text)
res = client.get(
url_for("ui.ui_views.preview_page", uuid="first"),
url_for("ui.ui_preview.preview_page", uuid="first"),
follow_redirects=True
)
@@ -64,7 +64,7 @@ def test_fetch_pdf(client, live_server, measure_memory_usage, datastore_path):
assert changed_md5.encode('utf-8') in res.data
res = client.get(
url_for("ui.ui_views.diff_history_page", uuid="first"),
url_for("ui.ui_diff.diff_history_page", uuid="first"),
follow_redirects=True
)
@@ -20,7 +20,7 @@ def test_fetch_pdf(client, live_server, measure_memory_usage, datastore_path):
wait_for_all_checks(client)
res = client.get(
url_for("ui.ui_views.preview_page", uuid="first"),
url_for("ui.ui_preview.preview_page", uuid="first"),
follow_redirects=True
)
@@ -50,7 +50,7 @@ def test_fetch_pdf(client, live_server, measure_memory_usage, datastore_path):
# The original checksum should be not be here anymore (cdio adds it to the bottom of the text)
res = client.get(
url_for("ui.ui_views.preview_page", uuid="first"),
url_for("ui.ui_preview.preview_page", uuid="first"),
follow_redirects=True
)
@@ -58,7 +58,7 @@ def test_fetch_pdf(client, live_server, measure_memory_usage, datastore_path):
assert changed_md5.encode('utf-8') in res.data
res = client.get(
url_for("ui.ui_views.diff_history_page", uuid="first"),
url_for("ui.ui_diff.diff_history_page", uuid="first"),
follow_redirects=True
)
+6 -6
View File
@@ -48,7 +48,7 @@ def test_headers_in_request(client, live_server, measure_memory_usage, datastore
# The service should echo back the request headers
res = client.get(
url_for("ui.ui_views.preview_page", uuid="first"),
url_for("ui.ui_preview.preview_page", uuid="first"),
follow_redirects=True
)
@@ -126,7 +126,7 @@ def test_body_in_request(client, live_server, measure_memory_usage, datastore_pa
# The service should echo back the body
res = client.get(
url_for("ui.ui_views.preview_page", uuid="first"),
url_for("ui.ui_preview.preview_page", uuid="first"),
follow_redirects=True
)
@@ -215,7 +215,7 @@ def test_method_in_request(client, live_server, measure_memory_usage, datastore_
# The service should echo back the request verb
res = client.get(
url_for("ui.ui_views.preview_page", uuid="first"),
url_for("ui.ui_preview.preview_page", uuid="first"),
follow_redirects=True
)
@@ -257,7 +257,7 @@ def test_ua_global_override(client, live_server, measure_memory_usage, datastore
wait_for_all_checks(client)
res = client.get(
url_for("ui.ui_views.preview_page", uuid="first"),
url_for("ui.ui_preview.preview_page", uuid="first"),
follow_redirects=True
)
@@ -281,7 +281,7 @@ def test_ua_global_override(client, live_server, measure_memory_usage, datastore
assert b"Updated watch." in res.data
wait_for_all_checks(client)
res = client.get(
url_for("ui.ui_views.preview_page", uuid="first"),
url_for("ui.ui_preview.preview_page", uuid="first"),
follow_redirects=True
)
assert b"agent-from-watch" in res.data
@@ -376,7 +376,7 @@ def test_headers_textfile_in_request(client, live_server, measure_memory_usage,
# The service should echo back the request verb
res = client.get(
url_for("ui.ui_views.preview_page", uuid="first"),
url_for("ui.ui_preview.preview_page", uuid="first"),
follow_redirects=True
)

Some files were not shown because too many files have changed in this diff Show More